Compare commits

..

12 Commits

Author SHA1 Message Date
Benjamin Lu
f62edda7e9 fix: remove unrelated merge artifacts 2026-03-23 19:54:58 -07:00
Benjamin Lu
7ce6408e7f Merge remote-tracking branch 'origin/main' into pr-7688
# Conflicts:
#	src/stores/executionStore.test.ts
2026-03-23 19:50:46 -07:00
Benjamin Lu
3185004a20 fix: scope executing node lookup to queued job 2026-03-23 19:40:34 -07:00
Jin Yi
98d56bdada fix: improve settings dialog UX (#10396)
## Summary

Reduce settings dialog size and autofocus search input for better
usability.

## Changes

- **What**: Reduce dialog size from `md` to `sm` (max-width 1400px →
960px); autofocus search input on open

## Review Focus

User feedback indicated the settings dialog was too wide and search
required an extra click to focus.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10396-fix-improve-settings-dialog-UX-32c6d73d365081e29eceed55afde1967)
by [Unito](https://www.unito.io)

Co-authored-by: Amp <amp@ampcode.com>
2026-03-23 18:45:40 -07:00
Benjamin Lu
fafaba09d1 Merge origin/main into qpo-progressbar-node-name 2026-03-06 14:08:19 -08:00
Alexander Brown
f349184290 Merge branch 'main' into qpo-progressbar-node-name 2026-01-05 16:06:03 -08:00
Csongor Czezar
530717983f fix: use public api 2026-01-02 15:00:56 -08:00
Csongor Czezar
6866bd4792 refactor: moved all the tests into the main executionstore test file 2026-01-02 14:46:01 -08:00
Csongor Czezar
1bfd617265 refactor: moving mock functions out of global 2026-01-02 14:17:38 -08:00
Csongor Czezar
bd1e15ad70 refactor: using test helpers 2026-01-02 13:52:05 -08:00
Csongor Czezar
a8458c6508 fix: code review items has been aligned 2026-01-02 13:52:05 -08:00
Csongor Czezar
e4110dd254 fix: QPO progress bar now shows node name in subgraphs 2026-01-02 13:52:04 -08:00
9 changed files with 184 additions and 234 deletions

View File

@@ -1,12 +1,12 @@
<template>
<tr
class="border-neutral-700 border-solid border-y"
class="border-y border-solid border-neutral-700"
:class="{
'opacity-50': runner.resolved,
'opacity-75': isLoading && runner.resolved
}"
>
<td class="text-center w-16">
<td class="w-16 text-center">
<TaskListStatusIcon :state="runner.state" :loading="isLoading" />
</td>
<td>
@@ -14,7 +14,7 @@
{{ task.name }}
</p>
<Button
class="inline-block mx-2"
class="mx-2 inline-block"
type="button"
:icon="PrimeIcons.INFO_CIRCLE"
severity="secondary"
@@ -22,11 +22,11 @@
@click="toggle"
/>
<Popover ref="infoPopover" class="block m-1 max-w-64 min-w-32">
<Popover ref="infoPopover" class="m-1 block max-w-64 min-w-32">
<span class="whitespace-pre-line">{{ task.description }}</span>
</Popover>
</td>
<td class="text-right px-4">
<td class="px-4 text-right">
<Button
:icon="task.button?.icon"
:label="task.button?.text"

View File

@@ -14,25 +14,18 @@ vi.mock('@/i18n', () => ({
const executionStore = reactive<{
isIdle: boolean
executionProgress: number
executingNode: unknown
executingNode: null | {
title?: string
type?: string
}
executingNodeProgress: number
nodeProgressStates: Record<string, unknown>
activeJob: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
isIdle: true,
executionProgress: 0,
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {},
activeJob: null
nodeProgressStates: {}
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -76,7 +69,6 @@ describe('useBrowserTabTitle', () => {
executionStore.executingNode = null
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activeJob = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
@@ -184,18 +176,12 @@ describe('useBrowserTabTitle', () => {
it('shows node execution title when executing a node using nodeProgressStates', async () => {
executionStore.isIdle = false
executionStore.executionProgress = 0.4
executionStore.executingNode = {
type: 'Foo'
}
executionStore.nodeProgressStates = {
'1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' }
}
executionStore.activeJob = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()

View File

@@ -74,14 +74,14 @@ export const useBrowserTabTitle = () => {
}
// If only one node is running
const [nodeId, state] = runningNodes[0]
const [, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'
const nodeLabel =
executionStore.executingNode?.type?.trim() ||
executionStore.executingNode?.title?.trim() ||
'Node'
return `${executionText.value}[${progress}%] ${nodeType}`
return `${executionText.value}[${progress}%] ${nodeLabel}`
})
const workflowTitle = computed(

View File

@@ -1889,9 +1889,7 @@
"extensionFileHint": "This may be due to the following script",
"promptExecutionError": "Prompt execution failed",
"accessRestrictedTitle": "Access Restricted",
"accessRestrictedMessage": "Your account is not authorized for this feature.",
"emptyFileInputTitle": "Missing File Inputs",
"emptyFileInputMessage": "The following nodes require a file to be selected: {nodeList}. Please upload or select files before running."
"accessRestrictedMessage": "Your account is not authorized for this feature."
},
"apiNodesSignInDialog": {
"title": "Sign In Required to Use API Nodes",

View File

@@ -1,5 +1,5 @@
<template>
<BaseModalLayout content-title="" data-testid="settings-dialog" size="md">
<BaseModalLayout content-title="" data-testid="settings-dialog" size="sm">
<template #leftPanelHeaderTitle>
<i class="icon-[lucide--settings]" />
<h2 class="text-neutral text-base">{{ $t('g.settings') }}</h2>
@@ -12,6 +12,7 @@
size="md"
:placeholder="$t('g.searchSettings') + '...'"
:debounce-time="128"
autofocus
@search="handleSearch"
/>
</div>

View File

@@ -146,45 +146,6 @@ import {
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
const FILE_INPUT_FIELDS = {
LoadImage: 'image',
LoadAudio: 'audio',
Load3D: 'model_file',
LoadVideo: 'video'
} as const
function isEmptyFileValue(value: unknown): boolean {
if (Array.isArray(value)) return false // linked input from another node
if (typeof value === 'string') return value.trim() === ''
return value == null
}
interface EmptyFileInputNode {
nodeId: string
classType: string
title: string
}
export function findEmptyFileInputNodes(
output: ComfyApiWorkflow,
nodeIds?: Set<string>
): EmptyFileInputNode[] {
const result: EmptyFileInputNode[] = []
for (const [nodeId, node] of Object.entries(output)) {
if (nodeIds && !nodeIds.has(nodeId)) continue
const field =
FILE_INPUT_FIELDS[node.class_type as keyof typeof FILE_INPUT_FIELDS]
if (field && isEmptyFileValue(node.inputs[field])) {
result.push({
nodeId,
classType: node.class_type,
title: node._meta?.title ?? node.class_type
})
}
}
return result
}
export function sanitizeNodeName(string: string) {
let entityMap = {
'&': '',
@@ -1654,25 +1615,6 @@ export class ComfyApp {
const queuedWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
const p = await this.graphToPrompt(this.rootGraph)
const targetNodeIds = isPartialExecution
? new Set(queueNodeIds!)
: undefined
const emptyFileInputNodes = findEmptyFileInputNodes(
p.output,
targetNodeIds
)
if (emptyFileInputNodes.length) {
const nodeList = emptyFileInputNodes
.map((n) => `#${n.nodeId} ${n.title}`)
.join(', ')
useDialogService().showErrorDialog(
new Error(t('errorDialog.emptyFileInputMessage', { nodeList })),
{ title: t('errorDialog.emptyFileInputTitle') }
)
break
}
const queuedNodes = collectAllNodes(this.rootGraph)
try {
api.authToken = comfyOrgAuthToken
@@ -1692,6 +1634,7 @@ export class ComfyApp {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
promptOutput: p.output,
workflow: queuedWorkflow
})
}

View File

@@ -1,120 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { ComfyApiWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { findEmptyFileInputNodes } from './app'
function makeNode(
classType: string,
inputs: Record<string, unknown>,
title?: string
) {
return {
class_type: classType,
inputs,
_meta: { title: title ?? classType }
}
}
describe('findEmptyFileInputNodes', () => {
it('detects LoadImage with empty image field', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('KSampler', { seed: 42 })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '1', classType: 'LoadImage', title: 'LoadImage' }
])
})
it('detects multiple empty file input nodes', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }, 'My Image'),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('LoadVideo', { video: 'file.mp4' })
}
const result = findEmptyFileInputNodes(output)
expect(result).toHaveLength(2)
expect(result[0]).toEqual({
nodeId: '1',
classType: 'LoadImage',
title: 'My Image'
})
expect(result[1]).toEqual({
nodeId: '2',
classType: 'LoadAudio',
title: 'LoadAudio'
})
})
it('returns empty array when all file inputs are populated', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: 'photo.png' }),
'2': makeNode('Load3D', { model_file: 'model.glb' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('returns empty array when no file input nodes exist', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('KSampler', { seed: 42 }),
'2': makeNode('CLIPTextEncode', { text: 'hello' })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('detects Load3D with empty model_file', () => {
const output: ComfyApiWorkflow = {
'5': makeNode('Load3D', { model_file: '' })
}
expect(findEmptyFileInputNodes(output)).toEqual([
{ nodeId: '5', classType: 'Load3D', title: 'Load3D' }
])
})
it('detects null file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: null })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects undefined file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: undefined })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('detects whitespace-only file input values', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ' ' })
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
it('skips linked inputs (array references to other nodes)', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: ['5', 0] })
}
expect(findEmptyFileInputNodes(output)).toEqual([])
})
it('filters to only specified node IDs when provided', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', { image: '' }),
'2': makeNode('LoadAudio', { audio: '' }),
'3': makeNode('KSampler', { seed: 42 })
}
const result = findEmptyFileInputNodes(output, new Set(['2', '3']))
expect(result).toEqual([
{ nodeId: '2', classType: 'LoadAudio', title: 'LoadAudio' }
])
})
it('detects missing file input field entirely', () => {
const output: ComfyApiWorkflow = {
'1': makeNode('LoadImage', {})
}
expect(findEmptyFileInputNodes(output)).toHaveLength(1)
})
})

View File

@@ -1,17 +1,22 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
type WorkflowConstructor = typeof WorkflowStoreModule.ComfyWorkflow
// Create mock functions that will be shared
const mockNodeExecutionIdToNodeLocatorId = vi.fn()
const mockNodeIdToNodeLocatorId = vi.fn()
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { NodeProgressState } from '@/schemas/apiSchema'
const workflowModuleState = vi.hoisted(() => ({
WorkflowClass: undefined as WorkflowConstructor | undefined
}))
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -20,6 +25,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
'@/platform/workflow/management/stores/workflowStore'
)
workflowModuleState.WorkflowClass = ComfyWorkflow
return {
ComfyWorkflow,
useWorkflowStore: vi.fn(() => ({
@@ -60,7 +66,7 @@ vi.mock('@/scripts/api', () => ({
}
}))
vi.mock('@/stores/imagePreviewStore', () => ({
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
revokePreviewsByExecutionId: vi.fn()
})
@@ -84,6 +90,29 @@ vi.mock('@/scripts/app', () => ({
}
}))
function createQueuedWorkflow(path: string = 'workflows/test.json') {
const { WorkflowClass } = workflowModuleState
if (!WorkflowClass) {
throw new Error('ComfyWorkflow mock class is not available')
}
return new WorkflowClass({
path,
modified: 0,
size: 0
})
}
function createPromptNode(title: string, classType: string) {
return {
inputs: {},
class_type: classType,
_meta: {
title
}
}
}
describe('useExecutionStore - NodeLocatorId conversions', () => {
let store: ReturnType<typeof useExecutionStore>
@@ -598,6 +627,103 @@ describe('useExecutionErrorStore - Node Error Lookups', () => {
})
})
describe('useExecutionStore - executingNode with subgraphs', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should find executing node info in root graph from queued prompt data', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'123': {
state: 'running',
value: 0,
max: 100,
display_node_id: '123',
prompt_id: 'test-prompt',
node_id: '123'
}
}
expect(store.executingNode).toEqual({
title: 'Test Node',
type: 'TestNode'
})
})
it('should find executing node info in subgraph using execution ID', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['456:789'],
promptOutput: {
'456:789': createPromptNode('Nested Node', 'NestedNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'456:789': {
state: 'running',
value: 0,
max: 100,
display_node_id: '456:789',
prompt_id: 'test-prompt',
node_id: '456:789'
}
}
expect(store.executingNode).toEqual({
title: 'Nested Node',
type: 'NestedNode'
})
})
it('should return null when no node is executing', () => {
store.nodeProgressStates = {}
expect(store.executingNode).toBeNull()
})
it('should return null when executing node metadata cannot be found', () => {
store.storeJob({
id: 'test-prompt',
nodes: ['123'],
promptOutput: {
'123': createPromptNode('Test Node', 'TestNode')
},
workflow: createQueuedWorkflow()
})
store.activeJobId = 'test-prompt'
store.nodeProgressStates = {
'999': {
state: 'running',
value: 0,
max: 100,
display_node_id: '999',
prompt_id: 'test-prompt',
node_id: '999'
}
}
expect(store.executingNode).toBeNull()
})
})
describe('useExecutionErrorStore - setMissingNodeTypes', () => {
let store: ReturnType<typeof useExecutionErrorStore>

View File

@@ -7,8 +7,7 @@ import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyNode,
ComfyWorkflowJSON,
ComfyApiWorkflow,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
@@ -34,6 +33,11 @@ import type { NodeLocatorId } from '@/types/nodeIdentification'
import { classifyCloudValidationError } from '@/utils/executionErrorUtil'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
interface ExecutionNodeInfo {
title?: string | null
type?: string | null
}
interface QueuedJob {
/**
* The nodes that are queued to be executed. The key is the node id and the
@@ -44,6 +48,25 @@ interface QueuedJob {
* The workflow that is queued to be executed
*/
workflow?: ComfyWorkflow
/**
* Queue-time node metadata keyed by execution ID.
* This stays stable even if the user switches workflows or edits the canvas.
*/
nodeLookup?: Record<string, ExecutionNodeInfo>
}
function buildExecutionNodeLookup(
promptOutput: ComfyApiWorkflow
): Record<string, ExecutionNodeInfo> {
return Object.fromEntries(
Object.entries(promptOutput).map(([executionId, node]) => [
executionId,
{
title: node._meta.title,
type: node.class_type
}
])
)
}
/**
@@ -166,21 +189,11 @@ export const useExecutionStore = defineStore('execution', () => {
() => new Set(executingNodeIds.value.map(String))
)
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
// For backward compatibility - returns the primary executing node info
const executingNode = computed<ExecutionNodeInfo | null>(() => {
if (!executingNodeId.value) return null
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
if (!workflow) return null
const canvasState: ComfyWorkflowJSON | null =
workflow.changeTracker?.activeState ?? null
if (!canvasState) return null
return (
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
null
)
return activeJob.value?.nodeLookup?.[String(executingNodeId.value)] ?? null
})
// This is the progress of the currently executing node (for backward compatibility)
@@ -536,10 +549,12 @@ export const useExecutionStore = defineStore('execution', () => {
function storeJob({
nodes,
id,
promptOutput,
workflow
}: {
nodes: string[]
id: string
promptOutput: ComfyApiWorkflow
workflow: ComfyWorkflow
}) {
queuedJobs.value[id] ??= { nodes: {} }
@@ -551,6 +566,7 @@ export const useExecutionStore = defineStore('execution', () => {
}, {}),
...queuedJob.nodes
}
queuedJob.nodeLookup = buildExecutionNodeLookup(promptOutput)
queuedJob.workflow = workflow
const wid = workflow?.activeState?.id ?? workflow?.initialState?.id
if (wid) {