Compare commits

...

2 Commits

Author SHA1 Message Date
GitHub Action
63929f1802 [automated] Apply ESLint and Oxfmt fixes 2026-03-12 09:37:54 +00:00
bymyself
71fccf3d8b feat: add workflow execution state indicators to tabs
- Add WorkflowExecutionResult type and lastExecutionResultByWorkflowId to executionStore
- Create useWorkflowExecutionState composable for tracking execution state
- Create WorkflowExecutionIndicator.vue with running/completed/error icons
- Integrate indicator into WorkflowTab.vue with 5s auto-clear for completed
- Add WorkflowExecutionBadge.vue for sidebar display
- Add i18n accessibility labels

Amp-Thread-ID: https://ampcode.com/threads/T-019c2557-1ba9-726a-8e93-978864992fd4
2026-03-12 02:34:50 -07:00
10 changed files with 470 additions and 4 deletions

View File

@@ -0,0 +1,47 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workflowExecution: {
running: 'Workflow is running',
completed: 'Workflow completed successfully',
error: 'Workflow execution failed'
}
}
}
})
describe('WorkflowExecutionIndicator', () => {
const mountWithI18n = (props: { state: WorkflowExecutionState }) =>
mount(WorkflowExecutionIndicator, {
props,
global: {
plugins: [i18n]
}
})
it('renders nothing for idle state', () => {
const wrapper = mountWithI18n({ state: 'idle' })
expect(wrapper.find('i').exists()).toBe(false)
})
it.each<{ state: WorkflowExecutionState; label: string }>([
{ state: 'running', label: 'Workflow is running' },
{ state: 'completed', label: 'Workflow completed successfully' },
{ state: 'error', label: 'Workflow execution failed' }
])('renders accessible icon for $state state', ({ state, label }) => {
const wrapper = mountWithI18n({ state })
const icon = wrapper.find('i')
expect(icon.exists()).toBe(true)
expect(icon.attributes('aria-label')).toBe(label)
})
})

View File

@@ -0,0 +1,25 @@
<template>
<i
v-if="state === 'running'"
class="icon-[lucide--loader-circle] size-4 shrink-0 animate-spin text-muted-foreground"
:aria-label="$t('workflowExecution.running')"
/>
<i
v-else-if="state === 'completed'"
class="icon-[lucide--circle-check] size-4 shrink-0 text-jade-600"
:aria-label="$t('workflowExecution.completed')"
/>
<i
v-else-if="state === 'error'"
class="icon-[lucide--circle-alert] size-4 shrink-0 text-coral-600"
:aria-label="$t('workflowExecution.error')"
/>
</template>
<script setup lang="ts">
import type { WorkflowExecutionState } from '@/stores/executionStore'
const { state } = defineProps<{
state: WorkflowExecutionState
}>()
</script>

View File

@@ -20,6 +20,10 @@
>
{{ workflowOption.workflow.filename }}
</span>
<WorkflowExecutionIndicator
v-if="showExecutionIndicator"
:state="executionState"
/>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
@@ -68,11 +72,12 @@ import {
ContextMenuSeparator,
ContextMenuTrigger
} from 'reka-ui'
import { computed, onUnmounted, ref } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
import Button from '@/components/ui/button/Button.vue'
import { useWorkflowExecutionState } from '@/composables/useWorkflowExecutionState'
import {
usePragmaticDraggable,
usePragmaticDroppable
@@ -87,6 +92,7 @@ import { useCommandStore } from '@/stores/commandStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
interface WorkflowOption {
@@ -116,6 +122,33 @@ const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
const workflowId = computed(() => {
const activeState = props.workflowOption.workflow.activeState
const initialState = props.workflowOption.workflow.initialState
return activeState?.id ?? initialState?.id
})
const { state: executionState, clearResult } =
useWorkflowExecutionState(workflowId)
const clearTimeoutId = ref<ReturnType<typeof setTimeout> | null>(null)
watch(executionState, (newState) => {
if (clearTimeoutId.value) {
clearTimeout(clearTimeoutId.value)
clearTimeoutId.value = null
}
if (newState === 'completed') {
clearTimeoutId.value = setTimeout(() => {
clearResult()
clearTimeoutId.value = null
}, 5000)
}
})
const showExecutionIndicator = computed(() => executionState.value !== 'idle')
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
settingStore.get('Comfy.Workflow.AutoSave')
@@ -287,6 +320,9 @@ usePragmaticDroppable(tabGetter, {
onUnmounted(() => {
popoverRef.value?.hidePopover()
if (clearTimeoutId.value) {
clearTimeout(clearTimeoutId.value)
}
})
</script>

View File

@@ -0,0 +1,71 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import { useWorkflowExecutionState } from './useWorkflowExecutionState'
const _workflowExecutionStates = ref(new Map<string, WorkflowExecutionState>())
const _clearWorkflowExecutionResult = vi.fn()
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
getWorkflowExecutionState: (wid: string | undefined) => {
if (!wid) return 'idle'
return _workflowExecutionStates.value.get(wid) ?? 'idle'
},
clearWorkflowExecutionResult: _clearWorkflowExecutionResult
})
}))
describe('useWorkflowExecutionState', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
_workflowExecutionStates.value = new Map()
})
it('returns idle when workflowId is undefined', () => {
const { state } = useWorkflowExecutionState(undefined)
expect(state.value).toBe('idle')
})
it('returns idle when no execution data exists', () => {
const { state } = useWorkflowExecutionState('workflow-1')
expect(state.value).toBe('idle')
})
it('returns state from execution store map', () => {
_workflowExecutionStates.value = new Map([['workflow-1', 'running']])
const { state } = useWorkflowExecutionState('workflow-1')
expect(state.value).toBe('running')
})
it('reacts to workflowId ref changes', () => {
const wfId = ref<string | undefined>('workflow-1')
_workflowExecutionStates.value = new Map([
['workflow-1', 'running'],
['workflow-2', 'error']
])
const { state } = useWorkflowExecutionState(wfId)
expect(state.value).toBe('running')
wfId.value = 'workflow-2'
expect(state.value).toBe('error')
})
it('clearResult delegates to executionStore', () => {
const { clearResult } = useWorkflowExecutionState('workflow-1')
clearResult()
expect(_clearWorkflowExecutionResult).toHaveBeenCalledWith('workflow-1')
})
it('clearResult does nothing when workflowId is undefined', () => {
const { clearResult } = useWorkflowExecutionState(undefined)
clearResult()
expect(_clearWorkflowExecutionResult).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,27 @@
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import type { WorkflowExecutionState } from '@/stores/executionStore'
import { useExecutionStore } from '@/stores/executionStore'
export function useWorkflowExecutionState(
workflowId: MaybeRefOrGetter<string | undefined>
) {
const executionStore = useExecutionStore()
const state = computed<WorkflowExecutionState>(() =>
executionStore.getWorkflowExecutionState(toValue(workflowId))
)
function clearResult() {
const wid = toValue(workflowId)
if (wid) {
executionStore.clearWorkflowExecutionResult(wid)
}
}
return {
state,
clearResult
}
}

View File

@@ -3589,5 +3589,10 @@
"builderMenu": {
"enterAppMode": "Enter app mode",
"exitAppBuilder": "Exit app builder"
},
"workflowExecution": {
"running": "Workflow is running",
"completed": "Workflow completed successfully",
"error": "Workflow execution failed"
}
}

View File

@@ -1626,6 +1626,18 @@ export class ComfyApp {
}
}
const activeWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow | undefined
const wid =
activeWorkflow?.activeState?.id ??
activeWorkflow?.initialState?.id
if (wid) {
executionStore.setWorkflowExecutionResultByWorkflowId(
String(wid),
'error'
)
}
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionErrorStore.showErrorOverlay()
}

View File

@@ -1,6 +1,7 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { app } from '@/scripts/app'
import type { NodeProgressState } from '@/schemas/apiSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { executionIdToNodeLocatorId } from '@/utils/graphTraversalUtil'
@@ -11,6 +12,7 @@ const mockNodeIdToNodeLocatorId = vi.fn()
const mockNodeLocatorIdToNodeExecutionId = vi.fn()
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
@@ -442,3 +444,111 @@ describe('useExecutionErrorStore - setMissingNodeTypes', () => {
expect(store.missingNodesError?.nodeTypes).toEqual(input)
})
})
describe('useExecutionStore - Workflow Execution State', () => {
let store: ReturnType<typeof useExecutionStore>
function mockWorkflow(id: string) {
return { activeState: { id } } as unknown as ComfyWorkflow
}
function mockNodeProgress(
state: NodeProgressState['state']
): NodeProgressState {
return { value: 0, max: 1, state, node_id: '1', prompt_id: 'job-1' }
}
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
describe('workflowExecutionStates', () => {
it('should return empty map when no workflows are running or have results', () => {
expect(store.workflowExecutionStates.size).toBe(0)
})
it('should return running state for workflows with active jobs', () => {
store.storeJob({
nodes: ['1', '2'],
id: 'job-1',
workflow: mockWorkflow('wf-1')
})
store.nodeProgressStatesByJob = {
'job-1': { '1': mockNodeProgress('running') }
}
expect(store.workflowExecutionStates.get('wf-1')).toBe('running')
})
it('should return completed state from last execution result', () => {
const next = new Map(store.lastExecutionResultByWorkflowId)
next.set('wf-1', {
state: 'completed',
timestamp: Date.now(),
promptId: 'job-1'
})
store.lastExecutionResultByWorkflowId = next
expect(store.workflowExecutionStates.get('wf-1')).toBe('completed')
})
it('should return error state from last execution result', () => {
const next = new Map(store.lastExecutionResultByWorkflowId)
next.set('wf-1', {
state: 'error',
timestamp: Date.now(),
promptId: 'job-1'
})
store.lastExecutionResultByWorkflowId = next
expect(store.workflowExecutionStates.get('wf-1')).toBe('error')
})
it('should prioritize running over completed/error', () => {
store.storeJob({
nodes: ['1', '2'],
id: 'job-2',
workflow: mockWorkflow('wf-1')
})
store.nodeProgressStatesByJob = {
'job-2': { '1': mockNodeProgress('running') }
}
const next = new Map(store.lastExecutionResultByWorkflowId)
next.set('wf-1', {
state: 'completed',
timestamp: Date.now(),
promptId: 'job-1'
})
store.lastExecutionResultByWorkflowId = next
expect(store.workflowExecutionStates.get('wf-1')).toBe('running')
})
})
describe('getWorkflowExecutionState', () => {
it('should return idle for undefined workflowId', () => {
expect(store.getWorkflowExecutionState(undefined)).toBe('idle')
})
it('should return idle for unknown workflowId', () => {
expect(store.getWorkflowExecutionState('unknown')).toBe('idle')
})
it('should return state from workflowExecutionStates map', () => {
const next = new Map(store.lastExecutionResultByWorkflowId)
next.set('wf-1', {
state: 'completed',
timestamp: Date.now(),
promptId: 'job-1'
})
store.lastExecutionResultByWorkflowId = next
expect(store.getWorkflowExecutionState('wf-1')).toBe('completed')
})
})
})

View File

@@ -46,6 +46,14 @@ interface QueuedJob {
workflow?: ComfyWorkflow
}
export type WorkflowExecutionResult = {
state: 'completed' | 'error'
timestamp: number
promptId?: string
}
export type WorkflowExecutionState = 'idle' | 'running' | 'completed' | 'error'
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -73,6 +81,95 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<string>>(new Set())
/**
* Map of workflow ID to last execution result for UI state display.
*/
const lastExecutionResultByWorkflowId = ref<
Map<string, WorkflowExecutionResult>
>(new Map())
function clearWorkflowExecutionResult(workflowId: string) {
if (!lastExecutionResultByWorkflowId.value.has(workflowId)) return
const next = new Map(lastExecutionResultByWorkflowId.value)
next.delete(workflowId)
lastExecutionResultByWorkflowId.value = next
}
function setWorkflowExecutionResult(
jobId: string,
state: 'completed' | 'error'
) {
const wid = jobIdToWorkflowId.value.get(jobId)
if (!wid) {
console.warn(
`[executionStore] No workflow mapping for job ${jobId}, execution result '${state}' dropped`
)
return
}
setWorkflowExecutionResultByWorkflowId(wid, state, jobId)
}
function setWorkflowExecutionResultByWorkflowId(
workflowId: string,
state: 'completed' | 'error',
promptId?: string
) {
const next = new Map(lastExecutionResultByWorkflowId.value)
next.set(workflowId, {
state,
timestamp: Date.now(),
promptId
})
lastExecutionResultByWorkflowId.value = next
}
function batchSetWorkflowExecutionResults(
results: Map<string, WorkflowExecutionResult>
) {
if (results.size === 0) return
const next = new Map(lastExecutionResultByWorkflowId.value)
for (const [workflowId, result] of results) {
next.set(workflowId, result)
}
lastExecutionResultByWorkflowId.value = next
}
/**
* Computed map of workflow ID to execution state for reactive UI updates.
*/
const workflowExecutionStates = computed<Map<string, WorkflowExecutionState>>(
() => {
const states = new Map<string, WorkflowExecutionState>()
// Mark running workflows
for (const jobId of runningJobIds.value) {
const workflowId = jobIdToWorkflowId.value.get(jobId)
if (workflowId) {
states.set(workflowId, 'running')
}
}
// Add completed/error states for workflows not currently running
for (const [
workflowId,
result
] of lastExecutionResultByWorkflowId.value) {
if (!states.has(workflowId)) {
states.set(workflowId, result.state)
}
}
return states
}
)
function getWorkflowExecutionState(
workflowId: string | undefined
): WorkflowExecutionState {
if (!workflowId) return 'idle'
return workflowExecutionStates.value.get(workflowId) ?? 'idle'
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
@@ -254,12 +351,13 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
const jobId = e.detail.prompt_id
setWorkflowExecutionResult(jobId, 'completed')
if (isCloud && activeJobId.value) {
useTelemetry()?.trackExecutionSuccess({
jobId: activeJobId.value
})
}
const jobId = e.detail.prompt_id
resetExecutionState(jobId)
}
@@ -328,9 +426,11 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
const jobId = e.detail.prompt_id
setWorkflowExecutionResult(jobId, 'error')
if (isCloud) {
useTelemetry()?.trackExecutionError({
jobId: e.detail.prompt_id,
jobId,
nodeId: String(e.detail.node_id),
nodeType: e.detail.node_type,
error: e.detail.exception_message
@@ -591,6 +691,13 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath
ensureSessionWorkflowPath,
// Workflow execution result tracking
lastExecutionResultByWorkflowId,
clearWorkflowExecutionResult,
setWorkflowExecutionResultByWorkflowId,
batchSetWorkflowExecutionResults,
workflowExecutionStates,
getWorkflowExecutionState
}
})

View File

@@ -20,6 +20,7 @@ import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { getJobDetail } from '@/services/jobOutputCache'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import type { WorkflowExecutionResult } from '@/stores/executionStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
@@ -556,6 +557,31 @@ export const useQueueStore = defineStore('queue', () => {
.sort((a, b) => b.create_time - a.create_time)
.slice(0, toValue(maxHistoryItems))
const seenWorkflows = new Set<string>()
const batchResults = new Map<string, WorkflowExecutionResult>()
for (const job of sortedHistory) {
const workflowId = job.workflow_id
if (!workflowId || seenWorkflows.has(workflowId)) continue
seenWorkflows.add(workflowId)
const state =
job.status === 'failed'
? 'error'
: job.status === 'completed'
? 'completed'
: undefined
if (!state) continue
const existing =
executionStore.lastExecutionResultByWorkflowId.get(workflowId)
const jobTimestamp = job.create_time * 1000
if (existing && existing.timestamp > jobTimestamp) continue
batchResults.set(workflowId, {
state,
timestamp: jobTimestamp,
promptId: job.id
})
}
executionStore.batchSetWorkflowExecutionResults(batchResults)
// Reuse existing TaskItemImpl instances or create new
// Must recreate if outputs_count changed (e.g., API started returning it)
const existingByJobId = new Map(