Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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
  • Loading branch information
christian-byrne committed Feb 21, 2026
commit ed9a8a2aae69fe62608ddf20cc380ea64adc8c44
6 changes: 6 additions & 0 deletions src/components/sidebar/tabs/WorkflowsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@
>*</span
>
</template>
<template #after-label="{ node: treeNode }">
<WorkflowExecutionBadge
:workflow="treeNode.data as ComfyWorkflow"
/>
</template>
<template #actions="{ node: treeNode }">
<Button
class="close-workflow-button"
Expand Down Expand Up @@ -149,6 +154,7 @@ import TextDivider from '@/components/common/TextDivider.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import WorkflowExecutionBadge from '@/components/sidebar/tabs/workflows/WorkflowExecutionBadge.vue'
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'

import WorkflowExecutionIndicator from '@/components/topbar/WorkflowExecutionIndicator.vue'
import { useWorkflowExecutionState } from '@/composables/useWorkflowExecutionState'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'

const { workflow } = defineProps<{ workflow?: ComfyWorkflow }>()

const workflowId = computed(() => {
return workflow?.activeState?.id ?? workflow?.initialState?.id
})

const { state } = useWorkflowExecutionState(workflowId)
const showIndicator = computed(() => state.value !== 'idle')
</script>

<template>
<WorkflowExecutionIndicator v-if="showIndicator" :state="state" />
</template>
47 changes: 47 additions & 0 deletions src/components/topbar/WorkflowExecutionIndicator.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
25 changes: 25 additions & 0 deletions src/components/topbar/WorkflowExecutionIndicator.vue
Original file line number Diff line number Diff line change
@@ -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>
16 changes: 16 additions & 0 deletions src/components/topbar/WorkflowTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
<span class="workflow-label inline-block max-w-[150px] truncate text-sm">
{{ workflowOption.workflow.filename }}
</span>
<WorkflowExecutionIndicator
v-if="showExecutionIndicator"
:state="executionState"
/>
<div class="relative">
<span
v-if="shouldShowStatusIndicator"
Expand Down Expand Up @@ -45,6 +49,7 @@ import { computed, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import Button from '@/components/ui/button/Button.vue'
import { useWorkflowExecutionState } from '@/composables/useWorkflowExecutionState'
import {
usePragmaticDraggable,
usePragmaticDroppable
Expand All @@ -56,6 +61,7 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useWorkspaceStore } from '@/stores/workspaceStore'

import WorkflowExecutionIndicator from './WorkflowExecutionIndicator.vue'
import WorkflowTabPopover from './WorkflowTabPopover.vue'

interface WorkflowOption {
Expand All @@ -76,6 +82,16 @@ 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 } = useWorkflowExecutionState(workflowId)

const showExecutionIndicator = computed(() => executionState.value !== 'idle')

// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
settingStore.get('Comfy.Workflow.AutoSave')
Expand Down
71 changes: 71 additions & 0 deletions src/composables/useWorkflowExecutionState.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
27 changes: 27 additions & 0 deletions src/composables/useWorkflowExecutionState.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
5 changes: 5 additions & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -3146,5 +3146,10 @@
"duplicateName": "A secret with this name already exists",
"duplicateProvider": "A secret for this provider already exists"
}
},
"workflowExecution": {
"running": "Workflow is running",
"completed": "Workflow completed successfully",
"error": "Workflow execution failed"
}
}
12 changes: 12 additions & 0 deletions src/scripts/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,6 +1506,18 @@ export class ComfyApp {
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
executionStore.showErrorOverlay()
}

const activeWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow | undefined
const wid =
activeWorkflow?.activeState?.id ??
activeWorkflow?.initialState?.id
if (wid) {
executionStore.setWorkflowExecutionResultByWorkflowId(
String(wid),
'error'
)
}
this.canvas.draw(true, true)
}
break
Expand Down
Loading