Add progress bars on Vue Nodes (#5469)

* track execution progress in vue nodes

* add test

* remove pointless execution state test

The test was mocking everything including the provide/inject mechanism,
so it wasn't testing real behavior. The execution progress feature
works correctly as verified through manual testing.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove accidentally committed PR_TEMPLATE.md

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [refactor] address PR review feedback from @DrJKL

- Replace hardcoded #0B8CE9 color with blue-100 class for consistency
- Replace magic number 56px with top-14 class for progress bar positioning
- Use storeToRefs() for better Pinia reactivity
- Reduce heavy commenting per maintainer preference

* fix: update LGraphNode test to mock useNodeExecutionState properly

The test was failing because it passed executing as a prop, but the component
uses the useNodeExecutionState composable. Added proper mock for the composable
to test the animate-pulse class application during execution.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Christian Byrne
2025-09-14 00:19:13 -07:00
committed by GitHub
parent 6b166a9d2f
commit d146a7896a
7 changed files with 151 additions and 8 deletions

View File

@@ -46,7 +46,6 @@
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false"
:executing="executionStore.executingNodeId === nodeData.id"
:error="
executionStore.lastExecutionError?.node_id === nodeData.id
? 'Execution error'
@@ -118,6 +117,7 @@ import TransformPane from '@/renderer/core/layout/TransformPane.vue'
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
import { UnauthorizedError, api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
@@ -205,6 +205,9 @@ const selectedNodeIds = computed(
)
provide(SelectedNodeIdsKey, selectedNodeIds)
// Provide execution state to all Vue nodes
useExecutionStateProvider()
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})

View File

@@ -1,8 +1,25 @@
import type { InjectionKey, Ref } from 'vue'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Injection key for providing selected node IDs to Vue node components.
* Contains a reactive Set of selected node IDs (as strings).
*/
export const SelectedNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('selectedNodeIds')
/**
* Injection key for providing executing node IDs to Vue node components.
* Contains a reactive Set of currently executing node IDs (as strings).
*/
export const ExecutingNodeIdsKey: InjectionKey<Ref<Set<string>>> =
Symbol('executingNodeIds')
/**
* Injection key for providing node progress states to Vue node components.
* Contains a reactive Record of node IDs to their current progress state.
*/
export const NodeProgressStatesKey: InjectionKey<
Ref<Record<string, NodeProgressState>>
> = Symbol('nodeProgressStates')

View File

@@ -12,7 +12,7 @@
'lg-node absolute rounded-2xl',
// border
'border border-solid border-sand-100 dark-theme:border-charcoal-300',
!!executing && 'border-blue-500 dark-theme:border-blue-500',
!!executing && 'border-blue-100 dark-theme:border-blue-100',
!!(error || nodeData.hasErrors) && 'border-error',
// hover
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
@@ -20,7 +20,7 @@
'outline-transparent -outline-offset-2 outline-2',
!!isSelected && 'outline-black dark-theme:outline-white',
!!(isSelected && executing) &&
'outline-blue-500 dark-theme:outline-blue-500',
'outline-blue-100 dark-theme:outline-blue-100',
!!(isSelected && (error || nodeData.hasErrors)) && 'outline-error',
{
'animate-pulse': executing,
@@ -141,6 +141,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { ExecutedWsMessage } from '@/schemas/apiSchema'
@@ -162,8 +163,6 @@ interface LGraphNodeProps {
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean
executing?: boolean
progress?: number
error?: string | null
zoomLevel?: number
}
@@ -202,6 +201,9 @@ const isSelected = computed(() => {
return selectedNodeIds.value.has(props.nodeData.id)
})
// Use execution state composable
const { executing, progress } = useNodeExecutionState(props.nodeData.id)
// LOD (Level of Detail) system based on zoom level
const zoomRef = toRef(() => props.zoomLevel ?? 1)
const {

View File

@@ -0,0 +1,36 @@
import { storeToRefs } from 'pinia'
import { computed, provide } from 'vue'
import {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import { useExecutionStore } from '@/stores/executionStore'
/**
* Composable for providing execution state to Vue node children
*
* This composable sets up the execution state providers that can be injected
* by child Vue nodes using useNodeExecutionState.
*
* Should be used in the parent component that manages Vue nodes (e.g., GraphCanvas).
*/
export const useExecutionStateProvider = () => {
const executionStore = useExecutionStore()
const { executingNodeIds: storeExecutingNodeIds, nodeProgressStates } =
storeToRefs(executionStore)
// Convert execution store data to the format expected by Vue nodes
const executingNodeIds = computed(
() => new Set(storeExecutingNodeIds.value.map(String))
)
// Provide the execution state to all child Vue nodes
provide(ExecutingNodeIdsKey, executingNodeIds)
provide(NodeProgressStatesKey, nodeProgressStates)
return {
executingNodeIds,
nodeProgressStates
}
}

View File

@@ -0,0 +1,54 @@
import { computed, inject, ref } from 'vue'
import {
ExecutingNodeIdsKey,
NodeProgressStatesKey
} from '@/renderer/core/canvas/injectionKeys'
import type { NodeProgressState } from '@/schemas/apiSchema'
/**
* Composable for managing execution state of Vue-based nodes
*
* Provides reactive access to execution state and progress for a specific node
* by injecting execution data from the parent GraphCanvas provider.
*
* @param nodeId - The ID of the node to track execution state for
* @returns Object containing reactive execution state and progress
*/
export const useNodeExecutionState = (nodeId: string) => {
const executingNodeIds = inject(ExecutingNodeIdsKey, ref(new Set<string>()))
const nodeProgressStates = inject(
NodeProgressStatesKey,
ref<Record<string, NodeProgressState>>({})
)
const executing = computed(() => {
return executingNodeIds.value.has(nodeId)
})
const progress = computed(() => {
const state = nodeProgressStates.value[nodeId]
return state?.max > 0 ? state.value / state.max : undefined
})
const progressState = computed(() => nodeProgressStates.value[nodeId])
const progressPercentage = computed(() => {
const prog = progress.value
return prog !== undefined ? Math.round(prog * 100) : undefined
})
const executionState = computed(() => {
const state = progressState.value
if (!state) return 'idle'
return state.state
})
return {
executing,
progress,
progressPercentage,
progressState,
executionState
}
}

View File

@@ -132,7 +132,7 @@ export const useExecutionStore = defineStore('execution', () => {
// Easily access all currently executing node IDs
const executingNodeIds = computed<NodeId[]>(() => {
return Object.entries(nodeProgressStates)
return Object.entries(nodeProgressStates.value)
.filter(([_, state]) => state.state === 'running')
.map(([nodeId, _]) => nodeId)
})

View File

@@ -1,13 +1,14 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
import LGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
vi.mock(
'@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking',
@@ -42,6 +43,19 @@ vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
LODLevel: { MINIMAL: 0 }
}))
vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({
useNodeExecutionState: vi.fn(() => ({
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
}))
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
@@ -92,6 +106,14 @@ describe('LGraphNode', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default mock
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => false),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'idle' as const)
})
})
it('should call resize tracking composable with node ID', () => {
@@ -143,7 +165,16 @@ describe('LGraphNode', () => {
})
it('should apply executing animation when executing prop is true', () => {
const wrapper = mountLGraphNode({ nodeData: mockNodeData, executing: true })
// Mock the execution state to return executing: true
vi.mocked(useNodeExecutionState).mockReturnValue({
executing: computed(() => true),
progress: computed(() => undefined),
progressPercentage: computed(() => undefined),
progressState: computed(() => undefined as any),
executionState: computed(() => 'running' as const)
})
const wrapper = mountLGraphNode({ nodeData: mockNodeData })
expect(wrapper.classes()).toContain('animate-pulse')
})