Compare commits

...

4 Commits

Author SHA1 Message Date
pythongosssss
89e634d431 clear workflow state on close 2026-03-17 09:57:24 -07:00
pythongosssss
49e052ef97 rename statuses, add aria label 2026-03-17 09:33:19 -07:00
pythongosssss
83eed12c0e fix race with socket event 2026-03-17 06:43:50 -07:00
pythongosssss
e2d96e05f9 add tab status indicator (running/done/errored) 2026-03-17 06:28:27 -07:00
4 changed files with 418 additions and 3 deletions

View File

@@ -0,0 +1,224 @@
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { nextTick, reactive } from 'vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type { WorkflowStatus } from '@/stores/executionStore'
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: () => ({
currentUser: null,
isAuthenticated: false,
isLoading: false
})
}))
const mockExecutionStore = reactive({
workflowStatus: new Map<string, WorkflowStatus>(),
clearWorkflowStatus: vi.fn()
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => mockExecutionStore
}))
vi.mock('reka-ui', () => {
const passthrough = {
render() {
return (
this as unknown as { $slots: { default?: () => unknown } }
).$slots.default?.()
}
}
return {
ContextMenuRoot: passthrough,
ContextMenuTrigger: passthrough,
ContextMenuContent: passthrough,
ContextMenuItem: passthrough,
ContextMenuPortal: passthrough,
ContextMenuSeparator: passthrough,
Primitive: passthrough
}
})
vi.mock('@/composables/usePragmaticDragAndDrop', () => ({
usePragmaticDraggable: vi.fn(),
usePragmaticDroppable: vi.fn()
}))
vi.mock('@/composables/useWorkflowActionsMenu', () => ({
useWorkflowActionsMenu: () => ({
menuItems: { value: [] }
})
}))
const mockCloseWorkflow = vi.fn().mockResolvedValue(true)
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: () => ({
closeWorkflow: mockCloseWorkflow
})
}))
vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
useWorkflowThumbnail: () => ({
getThumbnail: vi.fn(() => null)
})
}))
vi.mock('./WorkflowTabPopover.vue', () => ({
default: { render: () => null }
}))
import WorkflowTab from './WorkflowTab.vue'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
type WorkflowTabProps = ComponentProps<typeof WorkflowTab>
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { close: 'Close' } } }
})
function makeWorkflowOption(overrides: Record<string, unknown> = {}) {
return {
value: 'test-key',
workflow: {
key: 'test-key',
path: '/workflows/test.json',
filename: 'test.json',
isPersisted: true,
isModified: false,
initialMode: 'default',
activeMode: 'default',
changeTracker: null,
...overrides
}
} as unknown as WorkflowTabProps['workflowOption']
}
function mountTab({
workflowOverrides = {},
activeWorkflowKey = 'other-key'
} = {}) {
return mount(WorkflowTab, {
global: {
plugins: [
createTestingPinia({
stubActions: false,
initialState: {
workspace: { shiftDown: false },
workflow: {
activeWorkflow: { key: activeWorkflowKey }
},
setting: {}
}
}),
i18n
],
stubs: {
WorkflowActionsList: true,
Button: {
template: '<button v-bind="$attrs"><slot /></button>'
}
}
},
props: {
workflowOption: makeWorkflowOption(workflowOverrides),
isFirst: false,
isLast: false
}
})
}
function findIndicator(wrapper: ReturnType<typeof mountTab>) {
return wrapper.find('[data-testid="job-state-indicator"]')
}
describe('WorkflowTab - job state indicator', () => {
beforeEach(() => {
mockExecutionStore.workflowStatus = new Map()
mockExecutionStore.clearWorkflowStatus.mockClear()
})
it.for(['running', 'completed', 'failed'] as const)(
'shows %s indicator from store',
(status) => {
mockExecutionStore.workflowStatus = new Map([
['/workflows/test.json', status]
])
const wrapper = mountTab()
const indicator = findIndicator(wrapper)
expect(indicator.exists()).toBe(true)
expect(indicator.attributes('data-state')).toBe(status)
}
)
it('shows unsaved dot when no job state and workflow is unsaved', () => {
const wrapper = mountTab({
workflowOverrides: { isPersisted: false }
})
expect(findIndicator(wrapper).exists()).toBe(false)
const dot = wrapper.find('[data-testid="unsaved-indicator"]')
expect(dot.exists()).toBe(true)
expect(dot.text()).toBe('•')
})
it('does not show job indicator on active tab', () => {
mockExecutionStore.workflowStatus = new Map([
['/workflows/test.json', 'completed']
])
const wrapper = mountTab({ activeWorkflowKey: 'test-key' })
expect(findIndicator(wrapper).exists()).toBe(false)
})
it('job state replaces unsaved dot', () => {
mockExecutionStore.workflowStatus = new Map([
['/workflows/test.json', 'running']
])
const wrapper = mountTab({
workflowOverrides: { isPersisted: false }
})
const indicator = findIndicator(wrapper)
expect(indicator.exists()).toBe(true)
expect(indicator.attributes('data-state')).toBe('running')
})
it('clears workflow status when tab becomes active', async () => {
mockExecutionStore.workflowStatus = new Map([
['/workflows/test.json', 'completed']
])
const wrapper = mountTab()
expect(findIndicator(wrapper).exists()).toBe(true)
const workflowStore = useWorkflowStore()
workflowStore.activeWorkflow = {
key: 'test-key'
} satisfies Partial<LoadedComfyWorkflow> as LoadedComfyWorkflow
await nextTick()
expect(mockExecutionStore.clearWorkflowStatus).toHaveBeenCalledWith(
'/workflows/test.json'
)
})
it('clears workflow status when close succeeds', async () => {
mockCloseWorkflow.mockResolvedValue(true)
const wrapper = mountTab()
await wrapper.find('button[aria-label="Close"]').trigger('click')
expect(mockCloseWorkflow).toHaveBeenCalled()
expect(mockExecutionStore.clearWorkflowStatus).toHaveBeenCalledWith(
'/workflows/test.json'
)
})
})

View File

@@ -21,8 +21,26 @@
{{ workflowOption.workflow.filename }}
</span>
<div class="relative">
<i
v-if="jobState"
data-testid="job-state-indicator"
:data-state="jobState"
:aria-label="jobStateLabel"
:class="
cn(
'absolute top-1/2 left-1/2 z-10 size-4 -translate-1/2 group-hover:hidden',
jobState === 'running' &&
'icon-[lucide--loader-circle] animate-spin text-muted-foreground',
jobState === 'completed' &&
'icon-[lucide--circle-check] text-success-background',
jobState === 'failed' &&
'icon-[lucide--octagon-alert] text-destructive-background'
)
"
/>
<span
v-if="shouldShowStatusIndicator"
v-else-if="shouldShowStatusIndicator"
data-testid="unsaved-indicator"
class="absolute top-1/2 left-1/2 z-10 w-4 -translate-1/2 bg-(--comfy-menu-bg) text-2xl font-bold group-hover:hidden"
></span
>
@@ -68,7 +86,7 @@ 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'
@@ -84,8 +102,10 @@ import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workfl
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowThumbnail } from '@/renderer/core/thumbnail/useWorkflowThumbnail'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import type { WorkflowMenuItem } from '@/types/workflowMenuItem'
import { cn } from '@/utils/tailwindUtil'
import WorkflowTabPopover from './WorkflowTabPopover.vue'
@@ -112,6 +132,7 @@ const { t } = useI18n()
const workspaceStore = useWorkspaceStore()
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const executionStore = useExecutionStore()
const workflowTabRef = ref<HTMLElement | null>(null)
const popoverRef = ref<InstanceType<typeof WorkflowTabPopover> | null>(null)
const workflowThumbnail = useWorkflowThumbnail()
@@ -159,6 +180,27 @@ const isActiveTab = computed(() => {
return workflowStore.activeWorkflow?.key === props.workflowOption.workflow.key
})
const jobState = computed(() => {
const path = props.workflowOption.workflow.path
if (!path || isActiveTab.value) return null
return executionStore.workflowStatus.get(path) ?? null
})
const jobStateLabelMap: Record<string, string> = {
running: 'g.running',
completed: 'g.completed',
failed: 'g.failed'
}
const jobStateLabel = computed(() =>
jobState.value ? t(jobStateLabelMap[jobState.value]) : undefined
)
watch(isActiveTab, (isActive) => {
const path = props.workflowOption.workflow.path
if (isActive && path) executionStore.clearWorkflowStatus(path)
})
const thumbnailUrl = computed(() => {
return workflowThumbnail.getThumbnail(props.workflowOption.workflow.key)
})
@@ -191,6 +233,8 @@ const closeWorkflows = async (options: WorkflowOption[]) => {
// User clicked cancel
break
}
const path = opt.workflow.path
if (path) executionStore.clearWorkflowStatus(path)
}
}

View File

@@ -430,6 +430,114 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - workflowStatus', () => {
let store: ReturnType<typeof useExecutionStore>
function fireExecutionStart(jobId: string) {
const handler = apiEventHandlers.get('execution_start')
if (!handler) throw new Error('execution_start handler not bound')
handler(
new CustomEvent('execution_start', { detail: { prompt_id: jobId } })
)
}
function fireExecutionSuccess(jobId: string) {
const handler = apiEventHandlers.get('execution_success')
if (!handler) throw new Error('execution_success handler not bound')
handler(
new CustomEvent('execution_success', { detail: { prompt_id: jobId } })
)
}
function fireExecutionError(jobId: string) {
const handler = apiEventHandlers.get('execution_error')
if (!handler) throw new Error('execution_error handler not bound')
handler(
new CustomEvent('execution_error', {
detail: {
prompt_id: jobId,
node_id: '1',
node_type: 'TestNode',
exception_message: 'fail',
exception_type: 'Error',
traceback: []
}
})
)
}
function callStoreJob(jobId: string, workflowPath: string) {
store.storeJob({
nodes: ['1'],
id: jobId,
workflow: { path: workflowPath } as Parameters<
typeof store.storeJob
>[0]['workflow']
})
}
beforeEach(() => {
vi.clearAllMocks()
apiEventHandlers.clear()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
store.bindExecutionEvents()
})
it('sets running on execution_start when path mapping exists', () => {
callStoreJob('job-1', '/workflows/a.json')
fireExecutionStart('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('running')
})
it('sets running via ensureSessionWorkflowPath when WS fires before HTTP', () => {
// WS fires first — no path mapping yet, setWorkflowStatus no-ops
fireExecutionStart('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBeUndefined()
// HTTP response arrives — ensureSessionWorkflowPath sees activeJobId match
callStoreJob('job-1', '/workflows/a.json')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('running')
})
it('does not set running when job already completed before HTTP arrives', () => {
// WS: start and success both fire before HTTP response
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
// HTTP arrives late — job is no longer active
callStoreJob('job-1', '/workflows/a.json')
expect(store.workflowStatus.get('/workflows/a.json')).toBeUndefined()
})
it('sets completed on execution_success', () => {
callStoreJob('job-1', '/workflows/a.json')
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('completed')
})
it('sets failed on execution_error', () => {
callStoreJob('job-1', '/workflows/a.json')
fireExecutionStart('job-1')
fireExecutionError('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('failed')
})
it('clearWorkflowStatus removes the entry', () => {
callStoreJob('job-1', '/workflows/a.json')
fireExecutionStart('job-1')
fireExecutionSuccess('job-1')
expect(store.workflowStatus.get('/workflows/a.json')).toBe('completed')
store.clearWorkflowStatus('/workflows/a.json')
expect(store.workflowStatus.has('/workflows/a.json')).toBe(false)
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
let store: ReturnType<typeof useExecutionErrorStore>

View File

@@ -53,6 +53,8 @@ interface QueuedJob {
*/
export const MAX_PROGRESS_JOBS = 1000
export type WorkflowStatus = 'running' | 'completed' | 'failed'
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -80,6 +82,27 @@ export const useExecutionStore = defineStore('execution', () => {
const initializingJobIds = ref<Set<string>>(new Set())
/**
* Per-workflow-path execution status for the current session.
* Updated by WebSocket event handlers; cleared by the UI when viewed.
*/
const workflowStatus = shallowRef<Map<string, WorkflowStatus>>(new Map())
function setWorkflowStatus(jobId: string, status: WorkflowStatus) {
const path = jobIdToSessionWorkflowPath.value.get(jobId)
if (!path) return
const next = new Map(workflowStatus.value)
next.set(path, status)
workflowStatus.value = next
}
function clearWorkflowStatus(path: string) {
if (!workflowStatus.value.has(path)) return
const next = new Map(workflowStatus.value)
next.delete(path)
workflowStatus.value = next
}
/**
* Cache for executionIdToNodeLocatorId lookups.
* Avoids redundant graph traversals during a single execution run.
@@ -257,6 +280,7 @@ export const useExecutionStore = defineStore('execution', () => {
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
}
setWorkflowStatus(activeJobId.value, 'running')
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -270,6 +294,7 @@ export const useExecutionStore = defineStore('execution', () => {
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'failed')
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
resetExecutionState(jobId)
}
@@ -286,6 +311,7 @@ export const useExecutionStore = defineStore('execution', () => {
})
}
const jobId = e.detail.prompt_id
setWorkflowStatus(jobId, 'completed')
resetExecutionState(jobId)
}
@@ -383,6 +409,8 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionError(e: CustomEvent<ExecutionErrorWsMessage>) {
setWorkflowStatus(e.detail.prompt_id, 'failed')
if (isCloud) {
useTelemetry()?.trackExecutionError({
jobId: e.detail.prompt_id,
@@ -574,6 +602,15 @@ export const useExecutionStore = defineStore('execution', () => {
else break
}
jobIdToSessionWorkflowPath.value = next
// If this job is still executing, mark the workflow as running.
// Handles the race where handleExecutionStart fired before the path
// mapping existed (WebSocket arrived before the HTTP response).
if (activeJobId.value === jobId && !workflowStatus.value.has(path)) {
const nextStatus = new Map(workflowStatus.value)
nextStatus.set(path, 'running')
workflowStatus.value = nextStatus
}
}
/**
@@ -652,6 +689,8 @@ export const useExecutionStore = defineStore('execution', () => {
nodeLocatorIdToExecutionId,
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath
ensureSessionWorkflowPath,
workflowStatus,
clearWorkflowStatus
}
})