Compare commits

...

22 Commits

Author SHA1 Message Date
GitHub Action
134e7f382c [automated] Apply ESLint and Oxfmt fixes 2026-03-17 14:29:59 -07:00
bymyself
065d2abe69 fix: update ComfyQueueButton test stubs for DropdownMenu wrapper 2026-03-17 14:29:59 -07:00
GitHub Action
30e17a2f38 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 14:29:59 -07:00
bymyself
6872fd84bc fix: rework queue menu to use DropdownMenu wrapper for consistent styling
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9789#discussion_r2926094202
2026-03-17 14:29:59 -07:00
bymyself
a7a6416b23 fix: move parallelNew locale key to generic g.new
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9789#discussion_r2926714687
2026-03-17 14:29:59 -07:00
GitHub Action
a7d4366760 [automated] Apply ESLint and Oxfmt fixes 2026-03-17 14:29:59 -07:00
bymyself
ada2fb2c96 feat: add concurrent execution onboarding dialog
New ConcurrentExecutionDialog.vue shows when feature is enabled but user
hasn't seen onboarding. Explains parallel execution and eye icon focus.
Dismissed via 'Got it' button which persists via settingStore.
2026-03-17 14:29:59 -07:00
bymyself
bf80131a29 fix: resolve lint errors and test failure in Phase 3
- Fix vue/no-useless-mustaches: use i18n interpolation for running/queued
  labels instead of separate span + mustache space
- Fix @intlify/vue-i18n/no-raw-text: use i18n key for NEW badge text
- Fix JobGroupsList.test.ts: add createTestingPinia and mock
  useConcurrentExecution to resolve Pinia initialization error
2026-03-17 14:29:59 -07:00
bymyself
325c3021e4 feat: add Phase 3 UI components for concurrent job execution
- Eye toggle in QueueJobItem: visible for running jobs when concurrent
  execution is active, toggles which job's progress shows on canvas
- Parallel toggle in run mode dropdown: SwitchRoot below separator with
  [NEW] badge, disabled for auto-run modes, gated by feature flag
- N running/queued counters in top bar: Option A design with separate
  cancel (running) and clear (queued) actions when concurrent active
- Auto-queue guard: suppresses auto-re-queue when concurrent execution
  is enabled to prevent infinite parallel job spawning
- QueueInlineProgress already reads focused job state (no changes needed)
- Wire up JobGroupsList to pass isFocused prop and handle focus events
- Remove useConcurrentExecution from knip ignore (now imported)
2026-03-17 14:29:59 -07:00
bymyself
982e4a0262 feat: migrate consumers from activeJobId to focusedJobId (Phase 2) 2026-03-17 14:29:58 -07:00
bymyself
eb2100dd21 feat: add focusedJobId for concurrent job execution support 2026-03-17 14:29:58 -07:00
bymyself
17a175bd84 fix: mock useConcurrentExecution in executionStore tests and enable flag for concurrent test suites 2026-03-17 14:29:58 -07:00
bymyself
3b100462ce fix: restore isCloud import and add getServerFeature to test mock 2026-03-17 14:29:58 -07:00
bymyself
34105ac5b3 refactor: gate all concurrent execution changes behind feature flag, remove isCloud guards
- executionStore: all focusedJobId behavior, isIdle definition, progress
  filtering, and resetExecutionState now check isConcurrentExecutionEnabled
  and fall back to original single-job behavior when disabled
- useFeatureFlags: remove isCloud guard from concurrentExecutionEnabled so
  local/OSS can set their own feature flags
- coreSettings: remove isCloud guard on setting type visibility
2026-03-17 14:29:58 -07:00
bymyself
3a3ea0cce4 fix: use e.detail.prompt_id consistently in handleExecutionInterrupted and handleExecutionSuccess
Previously these handlers mixed activeJobId with e.detail.prompt_id,
which would cause bugs during concurrent execution when the active job
differs from the job being interrupted/completed.
2026-03-17 14:29:58 -07:00
bymyself
eca084484d fix: add dev override support and clamp maxConcurrentJobs
concurrentExecutionEnabled and maxConcurrentJobs now use getDevOverride
for consistent override precedence with other flags. maxConcurrentJobs
is clamped to a minimum of 1 to prevent invalid runtime values.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9786#discussion_r2927343686
2026-03-17 14:29:58 -07:00
bymyself
3c97c978c1 fix: use prompt_id to update correct job in execution handlers
handleExecutionCached and handleExecuted were updating activeJob, which
breaks when the focused job differs from the active job. Look up the job
by e.detail.prompt_id in queuedJobs instead.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9786#discussion_r2926758475
2026-03-17 14:29:58 -07:00
GitHub Action
53c9c69fdc [automated] Apply ESLint and Oxfmt fixes 2026-03-17 14:29:58 -07:00
bymyself
f50bb0840b feat: integrate concurrent execution with useFeatureFlags pattern
Add CONCURRENT_EXECUTION_ENABLED and MAX_CONCURRENT_JOBS to
ServerFeatureFlag enum. Add reactive getters in useFeatureFlags with
proper cloud + auth gating (matching teamWorkspacesEnabled pattern).

Update useConcurrentExecution to consume flags from useFeatureFlags
instead of reading remoteConfig directly, enabling dev overrides and
consistent flag resolution.
2026-03-17 14:29:58 -07:00
bymyself
d29a93421f fix: use queuedJobs membership for focus check instead of progress map
A freshly started job won't appear in nodeProgressStatesByJob until its
first progress_state arrives. Using queuedJobs membership avoids
incorrectly stealing focus during that window.

Also gate handleProgress to focused job only and reset
_executingNodeProgress on focus change to prevent progress drift
between concurrent jobs.

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9786#discussion_r2924372101
https://github.com/Comfy-Org/ComfyUI_frontend/pull/9786#discussion_r2924372109
2026-03-17 14:29:58 -07:00
bymyself
ba5c5f7194 fix: update TopMenuSection test for isIdle change, add knip ignore for pending composable 2026-03-17 14:29:58 -07:00
bymyself
55a73bcd0b feat: add focusedJobId for concurrent job execution support 2026-03-17 14:29:58 -07:00
26 changed files with 848 additions and 94 deletions

View File

@@ -384,6 +384,17 @@ describe('TopMenuSection', () => {
configureSettings(pinia, true)
const executionStore = useExecutionStore(pinia)
executionStore.activeJobId = 'job-1'
executionStore.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)

View File

@@ -32,16 +32,56 @@
<Suspense @resolve="comfyRunButtonResolved">
<ComfyRunButton />
</Suspense>
<Button
v-tooltip.bottom="cancelJobTooltipConfig"
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<template v-if="showConcurrentControls">
<div
class="flex items-center gap-1 rounded-lg bg-secondary-background px-2 py-1"
>
<span class="text-sm font-normal tabular-nums">
{{ t('menu.nRunning', { count: n(runningCount) }) }}
</span>
<Button
v-tooltip.bottom="cancelJobTooltipConfig"
variant="destructive"
size="icon"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<div
v-if="queuedCount > 0"
class="flex items-center gap-1 rounded-lg bg-secondary-background px-2 py-1"
>
<span class="text-sm font-normal tabular-nums">
{{ t('menu.nQueued', { count: n(queuedCount) }) }}
</span>
<Button
v-tooltip.bottom="clearQueueTooltipConfig"
variant="destructive"
size="icon"
:aria-label="
t('sideToolbar.queueProgressOverlay.clearQueueTooltip')
"
@click="handleClearQueue"
>
<i class="icon-[lucide--list-x] size-4" />
</Button>
</div>
</template>
<template v-else>
<Button
v-tooltip.bottom="cancelJobTooltipConfig"
variant="destructive"
size="icon"
:disabled="isExecutionIdle"
:aria-label="t('menu.interrupt')"
@click="cancelCurrentJob"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</template>
<Button
v-tooltip.bottom="queueHistoryTooltipConfig"
variant="secondary"
@@ -108,6 +148,7 @@ import StatusBadge from '@/components/common/StatusBadge.vue'
import QueueInlineProgress from '@/components/queue/QueueInlineProgress.vue'
import Button from '@/components/ui/button/Button.vue'
import { useQueueFeatureFlags } from '@/composables/queue/useQueueFeatureFlags'
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
@@ -143,6 +184,14 @@ const { isIdle: isExecutionIdle } = storeToRefs(executionStore)
const { activeJobsCount } = storeToRefs(queueStore)
const { activeSidebarTabId } = storeToRefs(sidebarTabStore)
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
const runningCount = computed(() => executionStore.runningJobIds.length)
const queuedCount = computed(() => queueStore.pendingTasks.length)
const showConcurrentControls = computed(
() => isConcurrentExecutionEnabled.value && !executionStore.isIdle
)
const position = computed(() => settingStore.get('Comfy.UseNewMenu'))
const visible = computed(() => position.value !== 'Disabled')
const { isQueuePanelV2Enabled, isRunProgressBarEnabled } =
@@ -371,6 +420,9 @@ watch(isDragging, (dragging) => {
const cancelJobTooltipConfig = computed(() =>
buildTooltipConfig(t('menu.interrupt'))
)
const clearQueueTooltipConfig = computed(() =>
buildTooltipConfig(t('sideToolbar.queueProgressOverlay.clearQueueTooltip'))
)
const queueHistoryTooltipConfig = computed(() =>
buildTooltipConfig(
t(

View File

@@ -89,11 +89,12 @@ function createWrapper() {
},
stubs: {
BatchCountEdit: BatchCountEditStub,
DropdownMenuRoot: { template: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuPortal: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<div><slot /></div>' }
DropdownMenu: {
template:
'<div><slot name="button" /><slot :item-class="\'\'" /></div>'
},
DropdownMenuItem: { template: '<div><slot /></div>' },
DropdownMenuSeparator: { template: '<div />' }
}
}
})

View File

@@ -19,8 +19,8 @@
{{ queueButtonLabel }}
</Button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<DropdownMenu>
<template #button>
<Button
variant="secondary"
size="unset"
@@ -30,54 +30,82 @@
>
<TinyChevronIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
:side-offset="4"
class="z-1000 min-w-44 rounded-lg border border-border-subtle bg-base-background p-1 shadow-interface"
</template>
<template #default="{ itemClass }">
<DropdownMenuItem
v-for="item in queueModeMenuItems"
:key="item.key"
as-child
@select.prevent="item.command"
>
<DropdownMenuItem
v-for="item in queueModeMenuItems"
:key="item.key"
as-child
@select.prevent="item.command"
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:variant="item.key === selectedQueueMode ? 'primary' : 'secondary'"
size="sm"
:class="cn(itemClass, queueMenuItemButtonClass)"
>
<Button
v-tooltip="{
value: item.tooltip,
showDelay: 600
}"
:variant="
item.key === selectedQueueMode ? 'primary' : 'secondary'
"
size="sm"
:class="queueMenuItemButtonClass"
>
{{ item.label }}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
{{ item.label }}
</Button>
</DropdownMenuItem>
<DropdownMenuSeparator
v-if="isParallelToggleVisible"
class="m-1 h-px bg-border-subtle"
/>
<DropdownMenuItem
v-if="isParallelToggleVisible"
:class="itemClass"
:disabled="isParallelToggleDisabled"
@select.prevent="toggleParallel"
>
<div class="flex flex-col select-none">
<div class="flex items-center gap-1.5">
<span class="text-sm text-text-primary">{{
t('menu.parallelExecution')
}}</span>
<StatusBadge :label="t('g.new')" class="text-[10px]" />
</div>
<span class="text-text-muted text-xs">{{
t('menu.parallelUpTo', { count: maxConcurrentJobs })
}}</span>
</div>
<SwitchRoot
:checked="parallelToggleChecked"
:disabled="isParallelToggleDisabled"
class="relative ml-auto h-5 w-9 shrink-0 cursor-pointer rounded-full bg-secondary-background transition-colors data-disabled:cursor-not-allowed data-[state=checked]:bg-primary-background"
@click.stop
@update:checked="onParallelToggle"
>
<SwitchThumb
class="block size-4 translate-x-0.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-[18px]"
/>
</SwitchRoot>
</DropdownMenuItem>
</template>
</DropdownMenu>
</ButtonGroup>
</template>
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuTrigger
DropdownMenuSeparator,
SwitchRoot,
SwitchThumb
} from 'reka-ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import BatchCountEdit from '@/components/actionbar/BatchCountEdit.vue'
import DropdownMenu from '@/components/common/DropdownMenu.vue'
import StatusBadge from '@/components/common/StatusBadge.vue'
import TinyChevronIcon from '@/components/actionbar/TinyChevronIcon.vue'
import Button from '@/components/ui/button/Button.vue'
import ButtonGroup from '@/components/ui/button-group/ButtonGroup.vue'
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
@@ -219,6 +247,23 @@ const queueButtonTooltip = computed(() => {
return t('menu.runWorkflow')
})
const { isFeatureEnabled, isUserEnabled, maxConcurrentJobs, setUserEnabled } =
useConcurrentExecution()
const isParallelToggleVisible = isFeatureEnabled
const isParallelToggleDisabled = computed(
() => selectedQueueMode.value !== 'disabled'
)
const parallelToggleChecked = isUserEnabled
function onParallelToggle(checked: boolean) {
void setUserEnabled(checked)
}
function toggleParallel() {
onParallelToggle(!parallelToggleChecked.value)
}
const commandStore = useCommandStore()
const queuePrompt = async (e: Event) => {
if (isStopInstantAction.value) {

View File

@@ -0,0 +1,47 @@
<template>
<Dialog
v-model:visible="visible"
:header="$t('concurrentExecution.onboarding.title')"
modal
:closable="true"
:draggable="false"
class="max-w-md"
>
<p class="text-sm text-muted-color">
{{ $t('concurrentExecution.onboarding.description') }}
</p>
<template #footer>
<Button autofocus @click="dismiss">
{{ $t('concurrentExecution.onboarding.gotIt') }}
</Button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import Dialog from 'primevue/dialog'
import { computed, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
const { isConcurrentExecutionEnabled, hasSeenOnboarding, markOnboardingSeen } =
useConcurrentExecution()
const dismissed = ref(false)
const visible = computed({
get: () =>
isConcurrentExecutionEnabled.value &&
!hasSeenOnboarding.value &&
!dismissed.value,
set: () => {
dismissed.value = true
}
})
async function dismiss() {
await markOnboardingSeen()
dismissed.value = true
}
</script>

View File

@@ -1,11 +1,19 @@
import { createTestingPinia } from '@pinia/testing'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import JobGroupsList from '@/components/queue/job/JobGroupsList.vue'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import type { TaskItemImpl } from '@/stores/queueStore'
vi.mock('@/composables/useConcurrentExecution', () => ({
useConcurrentExecution: () => ({
isConcurrentExecutionEnabled: { value: false }
})
}))
const QueueJobItemStub = defineComponent({
name: 'QueueJobItemStub',
props: {
@@ -56,6 +64,10 @@ const mountComponent = (groups: JobGroup[]) =>
}
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('JobGroupsList hover behavior', () => {
afterEach(() => {
vi.useRealTimers()

View File

@@ -24,10 +24,16 @@
:progress-current-percent="ji.progressCurrentPercent"
:running-node-name="ji.runningNodeName"
:active-details-id="activeDetailsId"
:is-focused="
isConcurrentExecutionEnabled && ji.state === 'running'
? ji.id === String(executionStore.focusedJobId ?? '')
: undefined
"
@cancel="emitCancelItem(ji)"
@delete="emitDeleteItem(ji)"
@menu="(ev) => $emit('menu', ji, ev)"
@view="$emit('viewItem', ji)"
@focus="executionStore.setFocusedJob(ji.id)"
@details-enter="onDetailsEnter"
@details-leave="onDetailsLeave"
/>
@@ -37,11 +43,16 @@
<script setup lang="ts">
import QueueJobItem from '@/components/queue/job/QueueJobItem.vue'
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { useJobDetailsHover } from '@/composables/queue/useJobDetailsHover'
import { useExecutionStore } from '@/stores/executionStore'
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const executionStore = useExecutionStore()
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
const emit = defineEmits<{
(e: 'cancelItem', item: JobListItem): void
(e: 'deleteItem', item: JobListItem): void

View File

@@ -170,6 +170,25 @@
<slot name="secondary">{{ rightText }}</slot>
</div>
</Transition>
<!-- Running job focus button - always visible (concurrent execution) -->
<Button
v-if="state === 'running' && isFocused !== undefined"
v-tooltip.top="focusTooltipConfig"
variant="textonly"
size="icon-sm"
:aria-label="t('queue.jobItem.focusTooltip')"
@click.stop="emit('focus')"
>
<i
:class="
cn(
'size-4',
isFocused ? 'icon-[lucide--eye]' : 'icon-[lucide--eye-off]',
isFocused ? 'text-text-primary' : 'text-text-secondary'
)
"
/>
</Button>
<!-- Running job cancel button - always visible -->
<Button
v-if="state === 'running' && computedShowClear"
@@ -212,7 +231,8 @@ const {
showMenu,
progressTotalPercent,
progressCurrentPercent,
activeDetailsId = null
activeDetailsId = null,
isFocused
} = defineProps<{
jobId: string
workflowId?: string
@@ -226,6 +246,7 @@ const {
progressTotalPercent?: number
progressCurrentPercent?: number
activeDetailsId?: string | null
isFocused?: boolean
}>()
const emit = defineEmits<{
@@ -233,6 +254,7 @@ const emit = defineEmits<{
(e: 'delete'): void
(e: 'menu', event: MouseEvent): void
(e: 'view'): void
(e: 'focus'): void
(e: 'details-enter', jobId: string): void
(e: 'details-leave', jobId: string): void
}>()
@@ -250,6 +272,9 @@ const {
const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
const moreTooltipConfig = computed(() => buildTooltipConfig(t('g.more')))
const focusTooltipConfig = computed(() =>
buildTooltipConfig(t('queue.jobItem.focusTooltip'))
)
const rowRef = ref<HTMLDivElement | null>(null)
const showDetails = computed(() => activeDetailsId === jobId)

View File

@@ -110,6 +110,8 @@ vi.mock('@/stores/queueStore', () => ({
let executionStoreMock: {
activeJobId: string | null
focusedJobId: string | null
runningJobIds: string[]
executingNode: null | { title?: string; type?: string }
isJobInitializing: (jobId?: string | number) => boolean
}
@@ -121,6 +123,8 @@ const ensureExecutionStore = () => {
if (!executionStoreMock) {
executionStoreMock = reactive({
activeJobId: null as string | null,
focusedJobId: null as string | null,
runningJobIds: [] as string[],
executingNode: null as null | { title?: string; type?: string },
isJobInitializing: (jobId?: string | number) =>
isJobInitializingMock(jobId)
@@ -484,6 +488,8 @@ describe('useJobList', () => {
]
executionStoreMock.activeJobId = 'active'
executionStoreMock.focusedJobId = 'active'
executionStoreMock.runningJobIds = ['active']
executionStoreMock.executingNode = { title: 'Render Node' }
totalPercent.value = 80
currentNodePercent.value = 40

View File

@@ -261,8 +261,12 @@ export function useJobList() {
const jobItems = computed<JobListItem[]>(() => {
return filteredTaskEntries.value.map(({ task, state }) => {
const isActive =
String(task.jobId ?? '') === String(executionStore.activeJobId ?? '')
const isRunning = executionStore.runningJobIds.includes(
String(task.jobId ?? '')
)
const isFocused =
String(task.jobId ?? '') === String(executionStore.focusedJobId ?? '')
const isActive = isRunning || isFocused
const showAddedHint = shouldShowAddedHint(task, state)
const promptKey = taskIdToKey(task.jobId)
const promptPreviewUrl =

View File

@@ -26,13 +26,23 @@ const executionStore = reactive<{
}
}
} | null
focusedJob: {
workflow: {
changeTracker: {
activeState: {
nodes: { id: number; type: string }[]
}
}
}
} | null
}>({
isIdle: true,
executionProgress: 0,
executingNode: null,
executingNodeProgress: 0,
nodeProgressStates: {},
activeJob: null
activeJob: null,
focusedJob: null
})
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => executionStore
@@ -77,6 +87,7 @@ describe('useBrowserTabTitle', () => {
executionStore.executingNodeProgress = 0
executionStore.nodeProgressStates = {}
executionStore.activeJob = null
executionStore.focusedJob = null
// reset setting and workflow stores
vi.mocked(settingStore.get).mockReturnValue('Enabled')
@@ -196,6 +207,15 @@ describe('useBrowserTabTitle', () => {
}
}
}
executionStore.focusedJob = {
workflow: {
changeTracker: {
activeState: {
nodes: [{ id: 1, type: 'Foo' }]
}
}
}
}
const scope: EffectScope = effectScope()
scope.run(() => useBrowserTabTitle())
await nextTick()

View File

@@ -77,7 +77,7 @@ export const useBrowserTabTitle = () => {
const [nodeId, state] = runningNodes[0]
const progress = Math.round((state.value / state.max) * 100)
const nodeType =
executionStore.activeJob?.workflow?.changeTracker?.activeState.nodes.find(
executionStore.focusedJob?.workflow?.changeTracker?.activeState.nodes.find(
(n) => String(n.id) === nodeId
)?.type || 'Node'

View File

@@ -0,0 +1,50 @@
import { computed } from 'vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useSettingStore } from '@/platform/settings/settingStore'
export function useConcurrentExecution() {
const settingStore = useSettingStore()
const { flags } = useFeatureFlags()
const isFeatureEnabled = computed(
() => flags.concurrentExecutionEnabled === true
)
const isUserEnabled = computed(
() => settingStore.get('Comfy.Cloud.ConcurrentExecution') === true
)
const isConcurrentExecutionEnabled = computed(
() => isFeatureEnabled.value && isUserEnabled.value
)
const maxConcurrentJobs = computed(() => flags.maxConcurrentJobs as number)
const hasSeenOnboarding = computed(
() =>
settingStore.get('Comfy.Cloud.ConcurrentExecution.OnboardingSeen') ===
true
)
async function setUserEnabled(enabled: boolean) {
await settingStore.set('Comfy.Cloud.ConcurrentExecution', enabled)
}
async function markOnboardingSeen() {
await settingStore.set(
'Comfy.Cloud.ConcurrentExecution.OnboardingSeen',
true
)
}
return {
isFeatureEnabled,
isUserEnabled,
isConcurrentExecutionEnabled,
maxConcurrentJobs,
hasSeenOnboarding,
setUserEnabled,
markOnboardingSeen
}
}

View File

@@ -315,7 +315,7 @@ export function useCoreCommands(): ComfyCommand[] {
label: 'Interrupt',
category: 'essentials' as const,
function: async () => {
await api.interrupt(executionStore.activeJobId)
await api.interrupt(executionStore.focusedJobId)
toastStore.add({
severity: 'info',
summary: t('g.interrupted'),

View File

@@ -26,7 +26,9 @@ export enum ServerFeatureFlag {
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
WORKFLOW_SHARING_ENABLED = 'workflow_sharing_enabled',
COMFYHUB_UPLOAD_ENABLED = 'comfyhub_upload_enabled',
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled'
COMFYHUB_PROFILE_GATE_ENABLED = 'comfyhub_profile_gate_enabled',
CONCURRENT_EXECUTION_ENABLED = 'concurrent_execution_enabled',
MAX_CONCURRENT_JOBS = 'max_concurrent_jobs'
}
/**
@@ -156,6 +158,31 @@ export function useFeatureFlags() {
remoteConfig.value.comfyhub_profile_gate_enabled,
false
)
},
get concurrentExecutionEnabled() {
const override = getDevOverride<boolean>(
ServerFeatureFlag.CONCURRENT_EXECUTION_ENABLED
)
if (override !== undefined) return override
return (
remoteConfig.value.concurrent_execution_enabled ??
api.getServerFeature(
ServerFeatureFlag.CONCURRENT_EXECUTION_ENABLED,
false
)
)
},
get maxConcurrentJobs() {
const override = getDevOverride<number>(
ServerFeatureFlag.MAX_CONCURRENT_JOBS
)
if (override !== undefined) return Math.max(1, override)
const configured =
remoteConfig.value.max_concurrent_jobs ??
api.getServerFeature(ServerFeatureFlag.MAX_CONCURRENT_JOBS, 1)
return Math.max(1, configured)
}
})

View File

@@ -123,6 +123,7 @@
"save": "Save",
"saveAnyway": "Save Anyway",
"saving": "Saving",
"new": "New",
"no": "No",
"cancel": "Cancel",
"close": "Close",
@@ -1004,7 +1005,18 @@
"settings": "Settings",
"help": "Help",
"queue": "Queue Panel",
"fullscreen": "Fullscreen"
"fullscreen": "Fullscreen",
"nRunning": "{count} running",
"nQueued": "{count} queued",
"parallelExecution": "Run jobs in parallel",
"parallelUpTo": "Up to {count}"
},
"concurrentExecution": {
"onboarding": {
"title": "Run Jobs in Parallel",
"description": "You can now run multiple workflows at the same time. Use the eye icon in the queue to control which job's progress is shown on the canvas.",
"gotIt": "Got it"
}
},
"tabMenu": {
"duplicateTab": "Duplicate Tab",
@@ -1222,6 +1234,9 @@
"jobAddedToQueue": "Job queued",
"jobQueueing": "Job queuing",
"completedIn": "Finished in {duration}",
"jobItem": {
"focusTooltip": "Progress currently shown on canvas"
},
"jobMenu": {
"openAsWorkflowNewTab": "Open as workflow in new tab",
"openWorkflowNewTab": "Open workflow in new tab",

View File

@@ -55,4 +55,6 @@ export type RemoteConfig = {
comfyhub_upload_enabled?: boolean
comfyhub_profile_gate_enabled?: boolean
sentry_dsn?: string
max_concurrent_jobs?: number
concurrent_execution_enabled?: boolean
}

View File

@@ -1268,5 +1268,21 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'boolean',
defaultValue: false,
versionAdded: '1.42.0'
},
{
id: 'Comfy.Cloud.ConcurrentExecution',
name: 'Run jobs in parallel',
tooltip:
'When enabled, multiple workflow runs execute concurrently instead of queuing sequentially.',
type: isCloud ? 'boolean' : 'hidden',
defaultValue: true,
versionAdded: '1.42.0'
},
{
id: 'Comfy.Cloud.ConcurrentExecution.OnboardingSeen',
name: 'Concurrent execution onboarding dialog seen',
type: 'hidden',
defaultValue: false,
versionAdded: '1.42.0'
}
]

View File

@@ -36,6 +36,9 @@ vi.mock('@/stores/executionStore', () => ({
get activeJobId() {
return activeJobIdRef.value
},
get focusedJobId() {
return activeJobIdRef.value
},
get jobIdToSessionWorkflowPath() {
return jobIdToWorkflowPathRef.value
}

View File

@@ -258,12 +258,12 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
function handleExecuted({ detail }: CustomEvent<ExecutedWsMessage>) {
const jobId = detail.prompt_id
if (jobId !== executionStore.activeJobId) return
if (jobId !== executionStore.focusedJobId) return
onNodeExecuted(jobId, detail)
}
watch(
() => executionStore.activeJobId,
() => executionStore.focusedJobId,
(jobId, oldJobId) => {
if (!isAppMode.value) return
if (oldJobId && oldJobId !== jobId) {
@@ -282,7 +282,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
() => jobPreviewStore.nodePreviewsByPromptId,
(previews) => {
if (!isAppMode.value) return
const jobId = executionStore.activeJobId
const jobId = executionStore.focusedJobId
if (!jobId) return
const preview = previews[jobId]
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)

View File

@@ -466,7 +466,9 @@ const zSettings = z.object({
'Comfy.RightSidePanel.IsOpen': z.boolean(),
'Comfy.RightSidePanel.ShowErrorsTab': z.boolean(),
'Comfy.Node.AlwaysShowAdvancedWidgets': z.boolean(),
'LiteGraph.Group.SelectChildrenOnClick': z.boolean()
'LiteGraph.Group.SelectChildrenOnClick': z.boolean(),
'Comfy.Cloud.ConcurrentExecution': z.boolean(),
'Comfy.Cloud.ConcurrentExecution.OnboardingSeen': z.boolean()
})
export type EmbeddingsResponse = z.infer<typeof zEmbeddingsResponse>

View File

@@ -764,6 +764,18 @@ export class ComfyApp {
useNodeOutputStore()
const blobUrl = createSharedObjectUrl(blob)
useJobPreviewStore().setPreviewUrl(jobId, blobUrl, displayNodeId)
// Only render preview on canvas if this is the focused job
const executionStore = useExecutionStore()
if (
jobId &&
executionStore.focusedJobId &&
jobId !== executionStore.focusedJobId
) {
releaseSharedObjectUrl(blobUrl)
return
}
// Ensure clean up if `executing` event is missed.
revokePreviewsByExecutionId(displayNodeId)
// Preview cleanup is handled in progress_state event to support multiple concurrent previews

View File

@@ -1,3 +1,4 @@
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import {
@@ -9,10 +10,12 @@ import {
export function setupAutoQueueHandler() {
const queueCountStore = useQueuePendingTaskCountStore()
const queueSettingsStore = useQueueSettingsStore()
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
let graphHasChanged = false
let internalCount = 0 // Use an internal counter here so it is instantly updated when re-queuing
api.addEventListener('graphChanged', () => {
if (isConcurrentExecutionEnabled.value) return
if (queueSettingsStore.mode === 'change') {
if (internalCount) {
graphHasChanged = true
@@ -29,6 +32,7 @@ export function setupAutoQueueHandler() {
async () => {
internalCount = queueCountStore.count
if (!internalCount && !app.lastExecutionError) {
if (isConcurrentExecutionEnabled.value) return
if (
isInstantRunningMode(queueSettingsStore.mode) ||
(queueSettingsStore.mode === 'change' && graphHasChanged)

View File

@@ -1,5 +1,6 @@
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { app } from '@/scripts/app'
import { MAX_PROGRESS_JOBS, useExecutionStore } from '@/stores/executionStore'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
@@ -15,6 +16,20 @@ import type { NodeProgressState } from '@/schemas/apiSchema'
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
import { createTestingPinia } from '@pinia/testing'
const mockConcurrentExecutionEnabled = ref(false)
vi.mock('@/composables/useConcurrentExecution', () => ({
useConcurrentExecution: () => ({
isConcurrentExecutionEnabled: mockConcurrentExecutionEnabled,
isFeatureEnabled: ref(false),
isUserEnabled: ref(false),
maxConcurrentJobs: ref(1),
hasSeenOnboarding: ref(false),
setUserEnabled: vi.fn(),
markOnboardingSeen: vi.fn()
})
}))
// Mock the workflowStore
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
const { ComfyWorkflow } = await vi.importActual<typeof WorkflowStoreModule>(
@@ -56,7 +71,8 @@ vi.mock('@/scripts/api', () => ({
apiEventHandlers.delete(event)
}),
clientId: 'test-client',
apiURL: vi.fn((path: string) => `/api${path}`)
apiURL: vi.fn((path: string) => `/api${path}`),
getServerFeature: vi.fn()
}
}))
@@ -430,6 +446,300 @@ describe('useExecutionStore - reconcileInitializingJobs', () => {
})
})
describe('useExecutionStore - focusedJobId management', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should initialize focusedJobId as null', () => {
expect(store.focusedJobId).toBeNull()
})
it('should set focusedJobId and update nodeProgressStates via setFocusedJob', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
},
'job-2': {
'node-2': {
value: 30,
max: 100,
state: 'running',
node_id: 'node-2',
prompt_id: 'job-2'
}
}
}
store.setFocusedJob('job-1')
expect(store.focusedJobId).toBe('job-1')
expect(store.nodeProgressStates).toEqual({
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
})
store.setFocusedJob('job-2')
expect(store.focusedJobId).toBe('job-2')
expect(store.nodeProgressStates).toEqual({
'node-2': {
value: 30,
max: 100,
state: 'running',
node_id: 'node-2',
prompt_id: 'job-2'
}
})
})
it('should clear nodeProgressStates when setFocusedJob is called with null', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
store.setFocusedJob('job-1')
expect(store.nodeProgressStates).not.toEqual({})
store.setFocusedJob(null)
expect(store.focusedJobId).toBeNull()
expect(store.nodeProgressStates).toEqual({})
})
it('should return undefined for focusedJob when no job is focused', () => {
expect(store.focusedJob).toBeUndefined()
})
it('should return the focused QueuedJob from focusedJob computed', () => {
store.queuedJobs = {
'job-1': { nodes: { n1: false, n2: true } }
}
store.setFocusedJob('job-1')
expect(store.focusedJob).toEqual({ nodes: { n1: false, n2: true } })
})
})
describe('useExecutionStore - isConcurrentExecutionActive', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should be false when no jobs are running', () => {
expect(store.isConcurrentExecutionActive).toBe(false)
})
it('should be false when only one job is running', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
expect(store.isConcurrentExecutionActive).toBe(false)
})
it('should be true when multiple jobs are running', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
},
'job-2': {
'node-2': {
value: 30,
max: 100,
state: 'running',
node_id: 'node-2',
prompt_id: 'job-2'
}
}
}
expect(store.isConcurrentExecutionActive).toBe(true)
})
})
describe('useExecutionStore - isIdle with multi-job', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
mockConcurrentExecutionEnabled.value = true
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should be true when no jobs are running', () => {
expect(store.isIdle).toBe(true)
})
it('should be false when at least one job is running', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 0,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
expect(store.isIdle).toBe(false)
})
it('should be true when all jobs are finished (not running)', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 100,
max: 100,
state: 'finished',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
expect(store.isIdle).toBe(true)
})
})
describe('useExecutionStore - resetExecutionState auto-advance', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should set focusedJobId to null when last running job finishes', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
}
}
store.activeJobId = 'job-1'
store.setFocusedJob('job-1')
// When the last job finishes, nodeProgressStatesByJob will be empty
// and focusedJobId should become null
store.nodeProgressStatesByJob = {}
store.setFocusedJob(null)
expect(store.focusedJobId).toBeNull()
expect(store.nodeProgressStates).toEqual({})
})
it('should not change focusedJobId when a non-focused job finishes', () => {
store.nodeProgressStatesByJob = {
'job-1': {
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
},
'job-2': {
'node-2': {
value: 30,
max: 100,
state: 'running',
node_id: 'node-2',
prompt_id: 'job-2'
}
}
}
store.setFocusedJob('job-1')
// job-2 finishes — focusedJobId should stay on job-1
const updated = { ...store.nodeProgressStatesByJob }
delete updated['job-2']
store.nodeProgressStatesByJob = updated
// Focus should still be on job-1
expect(store.focusedJobId).toBe('job-1')
expect(store.nodeProgressStates).toEqual({
'node-1': {
value: 50,
max: 100,
state: 'running',
node_id: 'node-1',
prompt_id: 'job-1'
}
})
})
})
describe('useExecutionStore - executionProgress from focusedJob', () => {
let store: ReturnType<typeof useExecutionStore>
beforeEach(() => {
vi.clearAllMocks()
mockConcurrentExecutionEnabled.value = true
setActivePinia(createTestingPinia({ stubActions: false }))
store = useExecutionStore()
})
it('should compute executionProgress from focused job nodes', () => {
store.queuedJobs = {
'job-1': { nodes: { n1: true, n2: false, n3: false } },
'job-2': { nodes: { n4: true, n5: true } }
}
store.setFocusedJob('job-1')
// 1 out of 3 done
expect(store.executionProgress).toBeCloseTo(1 / 3)
store.setFocusedJob('job-2')
// 2 out of 2 done
expect(store.executionProgress).toBe(1)
})
it('should return 0 when no job is focused', () => {
expect(store.executionProgress).toBe(0)
})
})
describe('useExecutionErrorStore - Node Error Lookups', () => {
let store: ReturnType<typeof useExecutionErrorStore>

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { useConcurrentExecution } from '@/composables/useConcurrentExecution'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
@@ -57,9 +58,11 @@ export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
const executionErrorStore = useExecutionErrorStore()
const { isConcurrentExecutionEnabled } = useConcurrentExecution()
const clientId = ref<string | null>(null)
const activeJobId = ref<string | null>(null)
const focusedJobId = ref<string | null>(null)
const queuedJobs = ref<Record<NodeId, QueuedJob>>({})
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
@@ -170,7 +173,10 @@ export const useExecutionStore = defineStore('execution', () => {
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
const workflow: ComfyWorkflow | undefined = activeJob.value?.workflow
const job = isConcurrentExecutionEnabled.value
? focusedJob.value
: activeJob.value
const workflow: ComfyWorkflow | undefined = job?.workflow
if (!workflow) return null
const canvasState: ComfyWorkflowJSON | null =
@@ -195,20 +201,36 @@ export const useExecutionStore = defineStore('execution', () => {
() => queuedJobs.value[activeJobId.value ?? '']
)
const focusedJob = computed<QueuedJob | undefined>(
() => queuedJobs.value[focusedJobId.value ?? '']
)
const isConcurrentExecutionActive = computed(
() => runningJobIds.value.length > 1
)
const _currentJob = computed(() =>
isConcurrentExecutionEnabled.value ? focusedJob.value : activeJob.value
)
const totalNodesToExecute = computed<number>(() => {
if (!activeJob.value) return 0
return Object.values(activeJob.value.nodes).length
if (!_currentJob.value) return 0
return Object.values(_currentJob.value.nodes).length
})
const isIdle = computed<boolean>(() => !activeJobId.value)
const isIdle = computed<boolean>(() =>
isConcurrentExecutionEnabled.value
? runningJobIds.value.length === 0
: !activeJobId.value
)
const nodesExecuted = computed<number>(() => {
if (!activeJob.value) return 0
return Object.values(activeJob.value.nodes).filter(Boolean).length
if (!_currentJob.value) return 0
return Object.values(_currentJob.value.nodes).filter(Boolean).length
})
const executionProgress = computed<number>(() => {
if (!activeJob.value) return 0
if (!_currentJob.value) return 0
const total = totalNodesToExecute.value
const done = nodesExecuted.value
return total > 0 ? done / total : 0
@@ -251,6 +273,15 @@ export const useExecutionStore = defineStore('execution', () => {
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
clearInitializationByJobId(activeJobId.value)
if (isConcurrentExecutionEnabled.value) {
// Auto-focus the first job, or if the current focused job is no longer running
if (!focusedJobId.value || !queuedJobs.value[focusedJobId.value]) {
focusedJobId.value = activeJobId.value
}
} else {
focusedJobId.value = activeJobId.value
}
// Ensure path mapping exists — execution_start can arrive via WebSocket
// before the HTTP response from queuePrompt triggers storeJob.
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
@@ -260,9 +291,10 @@ export const useExecutionStore = defineStore('execution', () => {
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
if (!activeJob.value) return
const job = queuedJobs.value[e.detail.prompt_id]
if (!job) return
for (const n of e.detail.nodes) {
activeJob.value.nodes[n] = true
job.nodes[n] = true
}
}
@@ -270,22 +302,21 @@ export const useExecutionStore = defineStore('execution', () => {
e: CustomEvent<ExecutionInterruptedWsMessage>
) {
const jobId = e.detail.prompt_id
if (activeJobId.value) clearInitializationByJobId(activeJobId.value)
clearInitializationByJobId(jobId)
resetExecutionState(jobId)
}
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
if (!activeJob.value) return
activeJob.value.nodes[e.detail.node] = true
const job = queuedJobs.value[e.detail.prompt_id]
if (!job) return
job.nodes[e.detail.node] = true
}
function handleExecutionSuccess(e: CustomEvent<ExecutionSuccessWsMessage>) {
if (isCloud && activeJobId.value) {
useTelemetry()?.trackExecutionSuccess({
jobId: activeJobId.value
})
}
const jobId = e.detail.prompt_id
if (jobId) {
useTelemetry()?.trackExecutionSuccess({ jobId })
}
resetExecutionState(jobId)
}
@@ -354,22 +385,34 @@ export const useExecutionStore = defineStore('execution', () => {
...nodeProgressStatesByJob.value,
[jobId]: nodes
}
evictOldProgressJobs()
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
const nodeState = nodes[executingNodeId.value]
_executingNodeProgress.value = {
value: nodeState.value,
max: nodeState.max,
prompt_id: nodeState.prompt_id,
node: nodeState.display_node_id || nodeState.node_id
const shouldUpdate = isConcurrentExecutionEnabled.value
? jobId === focusedJobId.value
: true
if (shouldUpdate) {
if (!isConcurrentExecutionEnabled.value) evictOldProgressJobs()
nodeProgressStates.value = nodes
// If we have progress for the currently executing node, update it for backwards compatibility
if (executingNodeId.value && nodes[executingNodeId.value]) {
const nodeState = nodes[executingNodeId.value]
_executingNodeProgress.value = {
value: nodeState.value,
max: nodeState.max,
prompt_id: nodeState.prompt_id,
node: nodeState.display_node_id || nodeState.node_id
}
}
}
}
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
if (
isConcurrentExecutionEnabled.value &&
e.detail.prompt_id !== focusedJobId.value
)
return
_executingNodeProgress.value = e.detail
}
@@ -492,8 +535,6 @@ export const useExecutionStore = defineStore('execution', () => {
* Reset execution-related state after a run completes or is stopped.
*/
function resetExecutionState(jobIdParam?: string | null) {
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
const jobId = jobIdParam ?? activeJobId.value ?? null
if (jobId) {
const map = { ...nodeProgressStatesByJob.value }
@@ -501,14 +542,46 @@ export const useExecutionStore = defineStore('execution', () => {
nodeProgressStatesByJob.value = map
useJobPreviewStore().clearPreview(jobId)
}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
if (isConcurrentExecutionEnabled.value) {
if (jobId === activeJobId.value || !jobIdParam) {
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
activeJobId.value = null
}
if (jobId === focusedJobId.value || !jobIdParam) {
// Auto-advance to next running job, or null
focusedJobId.value =
runningJobIds.value.find((id) => id !== jobId) ?? null
nodeProgressStates.value = focusedJobId.value
? (nodeProgressStatesByJob.value[focusedJobId.value] ?? {})
: {}
}
} else {
executionIdToLocatorCache.clear()
nodeProgressStates.value = {}
if (activeJobId.value) {
delete queuedJobs.value[activeJobId.value]
}
activeJobId.value = null
focusedJobId.value = null
}
activeJobId.value = null
_executingNodeProgress.value = null
executionErrorStore.clearPromptError()
}
function setFocusedJob(jobId: string | null) {
focusedJobId.value = jobId
_executingNodeProgress.value = null
if (jobId) {
nodeProgressStates.value = nodeProgressStatesByJob.value[jobId] ?? {}
} else {
nodeProgressStates.value = {}
}
}
function getNodeIdIfExecuting(nodeId: string | number) {
const nodeIdStr = String(nodeId)
return nodeIdStr.includes(':')
@@ -625,6 +698,10 @@ export const useExecutionStore = defineStore('execution', () => {
executingNodeId,
executingNodeIds,
activeJob,
focusedJobId,
focusedJob,
isConcurrentExecutionActive,
setFocusedJob,
totalNodesToExecute,
nodesExecuted,
executionProgress,

View File

@@ -27,6 +27,7 @@
<AssetExportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isDesktop" />
<ConcurrentExecutionDialog />
<MenuHamburger />
</template>
@@ -48,6 +49,7 @@ import { useI18n } from 'vue-i18n'
import { runWhenGlobalIdle } from '@/base/common/async'
import MenuHamburger from '@/components/MenuHamburger.vue'
import ConcurrentExecutionDialog from '@/components/dialog/ConcurrentExecutionDialog.vue'
import UnloadWindowConfirmDialog from '@/components/dialog/UnloadWindowConfirmDialog.vue'
import GraphCanvas from '@/components/graph/GraphCanvas.vue'
import GlobalToast from '@/components/toast/GlobalToast.vue'