[backport cloud/1.36] feat: add HoneyToast component for persistent progress notifications (#7918)

Backport of #7902 to cloud/1.36

Original PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7902

Cherry-picked merge commit e26e1f0c9e.

## Conflicts resolved
- **pnpm-lock.yaml**: Regenerated with `pnpm install`
-
**tests-ui/tests/components/dialog/footer/ManagerProgressFooter.test.ts**:
Removed (PR deletes this file along with the component it tested)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7918-backport-cloud-1-36-feat-add-HoneyToast-component-for-persistent-progress-notification-2e36d73d3650811a9f57f26c56b84c97)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: sno <snomiao@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Luke Mino-Altherr
2026-01-08 17:43:30 -08:00
committed by GitHub
parent e912b42fff
commit b9e6f3d9fa
25 changed files with 1389 additions and 1533 deletions

View File

@@ -7,7 +7,7 @@ import type { InlineConfig } from 'vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-docs'],
addons: ['@storybook/addon-docs', '@storybook/addon-mcp'],
framework: {
name: '@storybook/vue3-vite',
options: {}

View File

@@ -122,7 +122,10 @@ The project uses **Nx** for build orchestration and task management
- Prefer reactive props destructuring to `const props = defineProps<...>`
- Do not use `withDefaults` or runtime props declaration
- Do not import Vue macros unnecessarily
- Prefer `useModel` to separately defining a prop and emit
- Prefer `defineModel` to separately defining a prop and emit for v-model bindings
- Define slots via template usage, not `defineSlots`
- Use same-name shorthand for slot prop bindings: `:isExpanded` instead of `:is-expanded="isExpanded"`
- Derive component types using `vue-component-type-helpers` (`ComponentProps`, `ComponentSlots`) instead of separate type files
- Be judicious with addition of new refs or other state
- If it's possible to accomplish the design goals with just a prop, don't add a `ref`
- If it's possible to use the `ref` or prop directly, don't add a `computed`
@@ -271,6 +274,8 @@ When referencing Comfy-Org repos:
- Use `cn()` inline in the template when feasible instead of creating a `computed` to hold the value
- NEVER use `!important` or the `!` important prefix for tailwind classes
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
## Agent-only rules

View File

@@ -65,6 +65,7 @@
"@prettier/plugin-oxc": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@storybook/addon-docs": "catalog:",
"@storybook/addon-mcp": "catalog:",
"@storybook/vue3": "catalog:",
"@storybook/vue3-vite": "catalog:",
"@tailwindcss/vite": "catalog:",

443
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -29,6 +29,7 @@ catalog:
'@sentry/vue': ^8.48.0
'@sparkjsdev/spark': ^0.1.10
'@storybook/addon-docs': ^10.1.9
'@storybook/addon-mcp': 0.1.6
'@storybook/vue3': ^10.1.9
'@storybook/vue3-vite': ^10.1.9
'@tailwindcss/vite': ^4.1.12

View File

@@ -0,0 +1,292 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import { cn } from '@/utils/tailwindUtil'
import HoneyToast from './HoneyToast.vue'
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
...overrides
}
}
const meta: Meta<typeof HoneyToast> = {
title: 'Toast/HoneyToast',
component: HoneyToast,
parameters: {
layout: 'fullscreen'
},
decorators: [
() => ({
template: '<div class="h-screen bg-base-background p-8"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Expanded: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'completed',
progress: 1
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'running',
progress: 0.45
}),
createMockJob({
taskId: 'task-3',
assetName: 'vae-decoder.safetensors',
status: 'created'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground" />
<span class="font-bold text-base-foreground">lora-style.safetensors</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 of 3</span>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Completed: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(false)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
bytesDownloaded: 1000000,
progress: 1,
status: 'completed'
}),
createMockJob({
taskId: 'task-2',
assetId: 'asset-2',
assetName: 'lora-style.safetensors',
bytesTotal: 500000,
bytesDownloaded: 500000,
progress: 1,
status: 'completed'
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">All downloads completed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const WithError: Story = {
render: () => ({
components: { HoneyToast, Button, ProgressToastItem },
setup() {
const isExpanded = ref(true)
const jobs = [
createMockJob({
taskId: 'task-1',
assetName: 'model-v1.safetensors',
status: 'failed',
progress: 0.23
}),
createMockJob({
taskId: 'task-2',
assetName: 'lora-style.safetensors',
status: 'completed',
progress: 1
})
]
return { isExpanded, cn, jobs }
},
template: `
<HoneyToast v-model:expanded="isExpanded" :visible="true">
<template #default>
<div class="flex h-12 items-center justify-between border-b border-border-default px-4">
<h3 class="text-sm font-bold text-base-foreground">Download Queue</h3>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div class="flex flex-col gap-2">
<ProgressToastItem v-for="job in jobs" :key="job.taskId" :job="job" />
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex h-12 items-center justify-between border-t border-border-default px-4">
<div class="flex items-center gap-2 text-sm">
<i class="icon-[lucide--circle-alert] size-4 text-destructive-background" />
<span class="font-bold text-base-foreground">1 download failed</span>
</div>
<div class="flex items-center">
<Button variant="muted-textonly" size="icon" @click.stop="toggle">
<i :class="cn('size-4', isExpanded ? 'icon-[lucide--chevron-down]' : 'icon-[lucide--chevron-up]')" />
</Button>
<Button variant="muted-textonly" size="icon">
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</template>
</HoneyToast>
`
})
}
export const Hidden: Story = {
render: () => ({
components: { HoneyToast },
template: `
<div>
<p class="text-base-foreground">HoneyToast is hidden when visible=false. Nothing appears at the bottom.</p>
<HoneyToast :visible="false">
<template #default>
<div class="px-4 py-4">Content</div>
</template>
<template #footer>
<div class="h-12 px-4">Footer</div>
</template>
</HoneyToast>
</div>
`
})
}

View File

@@ -0,0 +1,137 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import HoneyToast from './HoneyToast.vue'
describe('HoneyToast', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
function mountComponent(
props: { visible: boolean; expanded?: boolean } = { visible: true }
): VueWrapper {
return mount(HoneyToast, {
props,
slots: {
default: (slotProps: { isExpanded: boolean }) =>
h(
'div',
{ 'data-testid': 'content' },
slotProps.isExpanded ? 'expanded' : 'collapsed'
),
footer: (slotProps: { isExpanded: boolean; toggle: () => void }) =>
h(
'button',
{
'data-testid': 'toggle-btn',
onClick: slotProps.toggle
},
slotProps.isExpanded ? 'Collapse' : 'Expand'
)
},
attachTo: document.body
})
}
it('renders when visible is true', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeTruthy()
wrapper.unmount()
})
it('does not render when visible is false', async () => {
const wrapper = mountComponent({ visible: false })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast).toBeFalsy()
wrapper.unmount()
})
it('passes is-expanded=false to slots by default', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
wrapper.unmount()
})
it('applies collapsed max-height class when collapsed', async () => {
const wrapper = mountComponent({ visible: true, expanded: false })
await nextTick()
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-0')).toBe(true)
wrapper.unmount()
})
it('has aria-live="polite" for accessibility', async () => {
const wrapper = mountComponent({ visible: true })
await nextTick()
const toast = document.body.querySelector('[role="status"]')
expect(toast?.getAttribute('aria-live')).toBe('polite')
wrapper.unmount()
})
it('supports v-model:expanded with reactive parent state', async () => {
const TestWrapper = defineComponent({
components: { HoneyToast },
setup() {
const expanded = ref(false)
return { expanded }
},
template: `
<HoneyToast :visible="true" v-model:expanded="expanded">
<template #default="slotProps">
<div data-testid="content">{{ slotProps.isExpanded ? 'expanded' : 'collapsed' }}</div>
</template>
<template #footer="slotProps">
<button data-testid="toggle-btn" @click="slotProps.toggle">
{{ slotProps.isExpanded ? 'Collapse' : 'Expand' }}
</button>
</template>
</HoneyToast>
`
})
const wrapper = mount(TestWrapper, { attachTo: document.body })
await nextTick()
const content = document.body.querySelector('[data-testid="content"]')
expect(content?.textContent).toBe('collapsed')
const toggleBtn = document.body.querySelector(
'[data-testid="toggle-btn"]'
) as HTMLButtonElement
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
toggleBtn?.click()
await nextTick()
expect(content?.textContent).toBe('expanded')
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
const expandableArea = document.body.querySelector(
'[role="status"] > div:first-child'
)
expect(expandableArea?.classList.contains('max-h-[400px]')).toBe(true)
wrapper.unmount()
})
})

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/utils/tailwindUtil'
const { visible } = defineProps<{
visible: boolean
}>()
const isExpanded = defineModel<boolean>('expanded', { default: false })
function toggle() {
isExpanded.value = !isExpanded.value
}
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<div
v-if="visible"
role="status"
aria-live="polite"
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-4/5 max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<slot :is-expanded />
</div>
<slot name="footer" :is-expanded :toggle />
</div>
</Transition>
</Teleport>
</template>

View File

@@ -0,0 +1,94 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import type { AssetDownload } from '@/stores/assetDownloadStore'
import ProgressToastItem from './ProgressToastItem.vue'
const meta: Meta<typeof ProgressToastItem> = {
title: 'Toast/ProgressToastItem',
component: ProgressToastItem,
parameters: {
layout: 'padded'
},
decorators: [
() => ({
template: '<div class="w-[400px] bg-base-background p-4"><story /></div>'
})
]
}
export default meta
type Story = StoryObj<typeof meta>
function createMockJob(overrides: Partial<AssetDownload> = {}): AssetDownload {
return {
taskId: 'task-1',
assetId: 'asset-1',
assetName: 'model-v1.safetensors',
bytesTotal: 1000000,
bytesDownloaded: 0,
progress: 0,
status: 'created',
...overrides
}
}
export const Pending: Story = {
args: {
job: createMockJob({
status: 'created',
assetName: 'sd-xl-base-1.0.safetensors'
})
}
}
export const Running: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.45,
assetName: 'lora-detail-enhancer.safetensors'
})
}
}
export const RunningAlmostComplete: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.92,
assetName: 'vae-ft-mse-840000.safetensors'
})
}
}
export const Completed: Story = {
args: {
job: createMockJob({
status: 'completed',
progress: 1,
assetName: 'controlnet-canny.safetensors'
})
}
}
export const Failed: Story = {
args: {
job: createMockJob({
status: 'failed',
progress: 0.23,
assetName: 'unreachable-model.safetensors'
})
}
}
export const LongFileName: Story = {
args: {
job: createMockJob({
status: 'running',
progress: 0.67,
assetName:
'very-long-model-name-with-lots-of-descriptive-text-v2.1-final-release.safetensors'
})
}
}

View File

@@ -30,9 +30,7 @@ const isPending = computed(() => job.status === 'created')
>
<div class="flex flex-col">
<span class="text-sm text-base-foreground">{{ job.assetName }}</span>
<span v-if="isRunning" class="text-xs text-muted-foreground">
{{ progressPercent }}%
</span>
<span v-if="isRunning" class="text-xs text-muted-foreground"> </span>
</div>
<div class="flex items-center gap-2">
@@ -49,9 +47,9 @@ const isPending = computed(() => job.status === 'created')
<template v-else-if="isRunning">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-primary-background"
class="icon-[lucide--loader-circle] size-4 animate-spin text-base-foreground"
/>
<span class="text-xs text-primary-background">
<span class="text-xs text-base-foreground">
{{ progressPercent }}%
</span>
</template>

View File

@@ -881,15 +881,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
},
{
id: 'Comfy.Manager.ToggleManagerProgressDialog',
icon: 'pi pi-spinner',
label: 'Toggle the Custom Nodes Manager Progress Bar',
versionAdded: '1.13.9',
function: () => {
dialogService.toggleManagerProgressDialog()
}
},
{
id: 'Comfy.User.OpenSignInDialog',
icon: 'pi pi-user',

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import Popover from 'primevue/popover'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import ProgressToastItem from '@/components/toast/ProgressToastItem.vue'
import Button from '@/components/ui/button/Button.vue'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
@@ -17,12 +19,10 @@ const isExpanded = ref(false)
const activeFilter = ref<'all' | 'completed' | 'failed'>('all')
const filterPopoverRef = ref<InstanceType<typeof Popover> | null>(null)
function toggle() {
isExpanded.value = !isExpanded.value
if (!isExpanded.value) {
filterPopoverRef.value?.hide()
}
}
whenever(
() => !isExpanded.value,
() => filterPopoverRef.value?.hide()
)
const filterOptions = [
{ value: 'all', label: 'all' },
@@ -83,189 +83,168 @@ function closeDialog() {
</script>
<template>
<Teleport to="body">
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="translate-y-full opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-full opacity-0"
>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div
v-if="visible"
class="fixed inset-x-0 bottom-6 z-50 mx-auto w-[80%] max-w-3xl overflow-hidden rounded-lg border border-border-default bg-base-background shadow-lg"
class="flex h-12 items-center justify-between border-b border-border-default px-4"
>
<div
:class="
cn(
'overflow-hidden transition-all duration-300',
isExpanded ? 'max-h-[400px]' : 'max-h-0'
)
"
>
<div
class="flex h-12 items-center justify-between border-b border-border-default px-4"
<h3 class="text-sm font-bold text-base-foreground">
{{ t('progressToast.importingModels') }}
</h3>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="md"
class="gap-1.5 px-2"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span>{{ activeFilterLabel }}</span>
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
<Popover
ref="filterPopoverRef"
append-to="body"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class:
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
}
}"
>
<h3 class="text-sm font-bold text-base-foreground">
{{ t('progressToast.importingModels') }}
</h3>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="md"
class="gap-1.5 px-2"
@click="onFilterClick"
>
<i class="icon-[lucide--list-filter] size-4" />
<span>{{ activeFilterLabel }}</span>
<i class="icon-[lucide--chevron-down] size-3" />
</Button>
<Popover
ref="filterPopoverRef"
append-to="body"
:dismissable="true"
:close-on-escape="true"
unstyled
:pt="{
root: { class: 'absolute z-50' },
content: {
class:
'bg-transparent border-none p-0 pt-2 rounded-lg shadow-lg'
}
}"
>
<div
class="flex min-w-[120px] flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<Button
v-for="option in filterOptions"
:key="option.value"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start bg-transparent',
activeFilter === option.value &&
'bg-secondary-background-selected'
)
"
@click="setFilter(option.value)"
>
{{ t(`progressToast.filter.${option.label}`) }}
</Button>
</div>
</Popover>
</div>
</div>
<div class="relative max-h-[300px] overflow-y-auto px-4 py-4">
<div
v-if="filteredJobs.length > 3"
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
/>
<div class="flex flex-col gap-2">
<ProgressToastItem
v-for="job in filteredJobs"
:key="job.taskId"
:job="job"
/>
</div>
<div
v-if="filteredJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
class="flex min-w-30 flex-col items-stretch rounded-lg border border-interface-stroke bg-interface-panel-surface px-2 py-3"
>
<span class="text-sm text-muted-foreground">
{{
t('progressToast.noImportsInQueue', {
filter: activeFilterLabel
})
}}
</span>
<Button
v-for="option in filterOptions"
:key="option.value"
variant="textonly"
size="sm"
:class="
cn(
'w-full justify-start bg-transparent',
activeFilter === option.value &&
'bg-secondary-background-selected'
)
"
@click="setFilter(option.value)"
>
{{ t(`progressToast.filter.${option.label}`) }}
</Button>
</div>
</div>
</Popover>
</div>
</div>
<div class="relative max-h-75 overflow-y-auto px-4 py-4">
<div
v-if="filteredJobs.length > 3"
class="absolute right-1 top-4 h-12 w-1 rounded-full bg-muted-foreground"
/>
<div class="flex flex-col gap-2">
<ProgressToastItem
v-for="job in filteredJobs"
:key="job.taskId"
:job="job"
/>
</div>
<div
class="flex h-12 items-center justify-between border-t border-border-default px-4"
v-if="filteredJobs.length === 0"
class="flex flex-col items-center justify-center py-6 text-center"
>
<div class="flex items-center gap-2 text-sm">
<template v-if="isInProgress">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="font-bold text-base-foreground">{{
currentJobName
}}</span>
</template>
<template v-else-if="failedJobs.length > 0">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<span class="font-bold text-base-foreground">
{{
t('progressToast.downloadsFailed', {
count: failedJobs.length
})
}}
</span>
</template>
<template v-else>
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">
{{ t('progressToast.allDownloadsCompleted') }}
</span>
</template>
</div>
<span class="text-sm text-muted-foreground">
{{
t('progressToast.noImportsInQueue', {
filter: activeFilterLabel
})
}}
</span>
</div>
</div>
</template>
<div class="flex items-center gap-2">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
<template #footer="{ toggle }">
<div
class="flex h-12 items-center justify-between border-t border-border-default px-4"
>
<div class="flex items-center gap-2 text-sm">
<template v-if="isInProgress">
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
<span class="font-bold text-base-foreground">{{
currentJobName
}}</span>
</template>
<template v-else-if="failedJobs.length > 0">
<i
class="icon-[lucide--circle-alert] size-4 text-destructive-background"
/>
<span class="font-bold text-base-foreground">
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
t('progressToast.downloadsFailed', {
count: failedJobs.length
})
}}
</span>
</template>
<template v-else>
<i class="icon-[lucide--check-circle] size-4 text-jade-600" />
<span class="font-bold text-base-foreground">
{{ t('progressToast.allDownloadsCompleted') }}
</span>
</template>
</div>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded
? t('contextMenu.Collapse')
: t('contextMenu.Expand')
<div class="flex items-center gap-2">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{
t('progressToast.progressCount', {
completed: completedCount,
total: totalCount
})
}}
</span>
<div class="flex items-center">
<Button
variant="muted-textonly"
size="icon"
:aria-label="
isExpanded ? t('contextMenu.Collapse') : t('contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
@click.stop="toggle"
>
<i
:class="
cn(
'size-4',
isExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-up]'
)
"
/>
</Button>
/>
</Button>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
<Button
v-if="!isInProgress"
variant="muted-textonly"
size="icon"
:aria-label="t('g.close')"
@click.stop="closeDialog"
>
<i class="icon-[lucide--x] size-4" />
</Button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
</HoneyToast>
</template>

View File

@@ -25,9 +25,7 @@ import type {
DialogComponentProps,
ShowDialogOptions
} from '@/stores/dialogStore'
import ManagerProgressDialogContent from '@/workbench/extensions/manager/components/ManagerProgressDialogContent.vue'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
@@ -229,30 +227,6 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: 'p-0!' },
header: { class: 'p-0! border-none' },
footer: { class: 'p-0! border-none' }
}
}
})
}
/**
* Shows a dialog requiring sign in for API nodes
* @returns Promise that resolves to true if user clicks login, false if cancelled
@@ -440,16 +414,6 @@ export const useDialogService = () => {
}
}
function toggleManagerProgressDialog(
props?: ComponentAttrs<typeof ManagerProgressDialogContent>
) {
if (dialogStore.isDialogOpen('global-manager-progress-dialog')) {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
} else {
showManagerProgressDialog({ props })
}
}
function showLayoutDialog(options: {
key: string
component: Component
@@ -548,7 +512,6 @@ export const useDialogService = () => {
showAboutDialog,
showExecutionErrorDialog,
showManagerDialog,
showManagerProgressDialog,
showApiNodesSignInDialog,
showSignInDialog,
showSubscriptionRequiredDialog,
@@ -559,7 +522,6 @@ export const useDialogService = () => {
showErrorDialog,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog,
showLayoutDialog,
showNodeConflictDialog
}

View File

@@ -109,7 +109,6 @@ The following table lists ALL 46 store instances in the system as of 2025-09-01:
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |

View File

@@ -18,6 +18,7 @@
<GlobalToast />
<RerouteMigrationToast />
<ModelImportProgressDialog />
<ManagerProgressToast />
<UnloadWindowConfirmDialog v-if="!isElectron()" />
<MenuHamburger />
</template>
@@ -80,6 +81,7 @@ import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { electronAPI, isElectron } from '@/utils/envUtil'
import LinearView from '@/views/LinearView.vue'
import ManagerProgressToast from '@/workbench/extensions/manager/components/ManagerProgressToast.vue'
setupAutoQueueHandler()
useProgressFavicon()

View File

@@ -1,186 +0,0 @@
import type { VueWrapper } from '@vue/test-utils'
import { mount } from '@vue/test-utils'
import { createPinia } from 'pinia'
import Button from '@/components/ui/button/Button.vue'
import PrimeVue from 'primevue/config'
import Panel from 'primevue/panel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
import ManagerProgressDialogContent from './ManagerProgressDialogContent.vue'
type ComponentInstance = InstanceType<typeof ManagerProgressDialogContent> & {
lastPanelRef: HTMLElement | null
onLogsAdded: () => void
handleScroll: (e: { target: HTMLElement }) => void
isUserScrolling: boolean
resetUserScrolling: () => void
collapsedPanels: Record<number, boolean>
togglePanel: (index: number) => void
}
const mockCollapse = vi.fn()
const defaultMockTaskLogs = [
{ taskName: 'Task 1', logs: ['Log 1', 'Log 2'] },
{ taskName: 'Task 2', logs: ['Log 3', 'Log 4'] }
]
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore', () => ({
useComfyManagerStore: vi.fn(() => ({
taskLogs: [...defaultMockTaskLogs],
succeededTasksLogs: [...defaultMockTaskLogs],
failedTasksLogs: [...defaultMockTaskLogs],
managerQueue: { historyCount: 2 },
isLoading: false
})),
useManagerProgressDialogStore: vi.fn(() => ({
isExpanded: true,
activeTabIndex: 0,
getActiveTabIndex: vi.fn(() => 0),
setActiveTabIndex: vi.fn(),
toggle: vi.fn(),
collapse: mockCollapse,
expand: vi.fn()
}))
}))
describe('ManagerProgressDialogContent', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCollapse.mockReset()
})
const mountComponent = ({
props = {}
}: Record<string, any> = {}): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(ManagerProgressDialogContent, {
props: {
...props
},
global: {
plugins: [PrimeVue, createPinia(), i18n],
components: {
Panel,
Button
}
}
}) as VueWrapper<ComponentInstance>
}
it('renders the correct number of panels', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findAllComponents(Panel).length).toBe(2)
})
it('expands the last panel by default', async () => {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.vm.collapsedPanels[1]).toBeFalsy()
})
it('toggles panel expansion when toggle method is called', async () => {
const wrapper = mountComponent()
await nextTick()
// Initial state - first panel should be collapsed
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
wrapper.vm.togglePanel(0)
await nextTick()
// After toggle - first panel should be expanded
expect(wrapper.vm.collapsedPanels[0]).toBe(true)
wrapper.vm.togglePanel(0)
await nextTick()
expect(wrapper.vm.collapsedPanels[0]).toBeFalsy()
})
it('displays the correct status for each panel', async () => {
const wrapper = mountComponent()
await nextTick()
// Expand all panels to see status text
const panels = wrapper.findAllComponents(Panel)
for (let i = 0; i < panels.length; i++) {
if (!wrapper.vm.collapsedPanels[i]) {
wrapper.vm.togglePanel(i)
await nextTick()
}
}
const panelsText = wrapper
.findAllComponents(Panel)
.map((panel) => panel.text())
expect(panelsText[0]).toContain('Completed ✓')
expect(panelsText[1]).toContain('Completed ✓')
})
it('auto-scrolls to bottom when new logs are added', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 0,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.onLogsAdded()
await nextTick()
// Check if scrollTop is set to scrollHeight (scrolled to bottom)
expect(mockScrollElement.scrollTop).toBe(200)
})
it('does not auto-scroll when user is manually scrolling', async () => {
const wrapper = mountComponent()
await nextTick()
const mockScrollElement = document.createElement('div')
Object.defineProperty(mockScrollElement, 'scrollHeight', { value: 200 })
Object.defineProperty(mockScrollElement, 'clientHeight', { value: 100 })
Object.defineProperty(mockScrollElement, 'scrollTop', {
value: 50,
writable: true
})
wrapper.vm.lastPanelRef = mockScrollElement
wrapper.vm.handleScroll({ target: mockScrollElement })
await nextTick()
expect(wrapper.vm.isUserScrolling).toBe(true)
// Now trigger the log update
wrapper.vm.onLogsAdded()
await nextTick()
// Check that scrollTop is not changed (should still be 50)
expect(mockScrollElement.scrollTop).toBe(50)
})
it('calls collapse method when component is unmounted', async () => {
const wrapper = mountComponent()
await nextTick()
wrapper.unmount()
expect(mockCollapse).toHaveBeenCalled()
})
})

View File

@@ -1,182 +0,0 @@
<template>
<div
class="overflow-hidden transition-all duration-300"
:class="{
'max-h-[500px]': isExpanded,
'm-0 max-h-0 p-0': !isExpanded
}"
>
<div
ref="sectionsContainerRef"
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
:class="{
'max-h-[450px]': isExpanded,
'max-h-0': !isExpanded
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 mt-2 rounded-lg"
>
<template #header>
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm leading-normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isInProgress(index)
? $t('g.inProgress')
: $t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
variant="textonly"
class="text-neutral-300"
@click="togglePanel(index)"
>
<i
:class="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
/>
</Button>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="h-64 overflow-y-auto rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
grow: index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-muted"
>
<pre class="break-words whitespace-pre-wrap">{{ logLine }}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const comfyManagerStore = useComfyManagerStore()
const progressDialogContent = useManagerProgressDialogStore()
const isInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
// Check if this task is in the running or pending queue
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
const allQueueTasks = [
...(taskQueue.running_queue || []),
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
}
const focusedLogs = computed(() => {
if (progressDialogContent.getActiveTabIndex() === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const isExpanded = computed(() => progressDialogContent.isExpanded)
const isCollapsed = computed(() => !isExpanded.value)
const collapsedPanels = ref<Record<number, boolean>>({})
const togglePanel = (index: number) => {
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: {
passive: true
}
})
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
const isAtBottom = (el: HTMLElement | null) => {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
const scrollLastPanelToBottom = () => {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
const scrollContentToBottom = () => {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
const resetUserScrolling = () => {
isUserScrolling.value = false
}
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
const onLogsAdded = () => {
// If user is scrolling manually, don't automatically scroll to bottom
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(isCollapsed, resetUserScrolling)
onMounted(() => {
scrollContentToBottom()
})
onBeforeUnmount(() => {
progressDialogContent.collapse()
})
</script>

View File

@@ -1,195 +0,0 @@
<template>
<div
class="flex w-full items-center justify-between px-6 py-2 shadow-lg"
:class="{
'rounded-t-none': progressDialogContent.isExpanded,
'rounded-lg': !progressDialogContent.isExpanded
}"
>
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ $t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-neutral-700">
{{ completedTasksCount }} {{ $t('g.progressCountOf') }}
{{ totalTasksCount }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
variant="secondary"
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
@click="handleRestart"
>
{{ $t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="
$t(
progressDialogContent.isExpanded
? 'contextMenu.Collapse'
: 'contextMenu.Expand'
)
"
@click.stop="progressDialogContent.toggle"
>
<i
:class="
progressDialogContent.isExpanded
? 'pi pi-chevron-up'
: 'pi pi-chevron-down'
"
/>
</Button>
<Button
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="$t('g.close')"
@click.stop="closeDialog"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const dialogStore = useDialogStore()
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
// State management for restart process
const isRestarting = ref<boolean>(false)
const isRestartCompleted = ref<boolean>(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const totalTasksCount = computed(() => {
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
const taskQueue = comfyManagerStore.taskQueue
const queuedTasks = taskQueue
? (taskQueue.running_queue?.length || 0) +
(taskQueue.pending_queue?.length || 0)
: 0
return completedTasks + queuedTasks
})
const closeDialog = () => {
dialogStore.closeDialog({ key: 'global-manager-progress-dialog' })
}
const fallbackTaskName = t('manager.installingDependencies')
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length) return fallbackTaskName
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? fallbackTaskName
})
const handleRestart = async () => {
// Store original toast setting value
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
// Run conflict detection in background after restart completion
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeDialog()
comfyManagerStore.resetTaskState()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
// If restart fails, restore settings and reset state
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeDialog() // Close dialog on error
throw error
}
}
</script>

View File

@@ -1,44 +0,0 @@
<template>
<div
v-if="progressDialogContent.isExpanded"
class="flex items-center px-4 py-2"
>
<TabMenu
v-model:active-index="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
</template>
<script setup lang="ts">
import TabMenu from 'primevue/tabmenu'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
const progressDialogContent = useManagerProgressDialogStore()
const comfyManagerStore = useComfyManagerStore()
const activeTabIndex = computed({
get: () => progressDialogContent.getActiveTabIndex(),
set: (value) => progressDialogContent.setActiveTabIndex(value)
})
const { t } = useI18n()
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
})
}
])
</script>

View File

@@ -0,0 +1,353 @@
<script setup lang="ts">
import { useEventListener, useScroll, whenever } from '@vueuse/core'
import Panel from 'primevue/panel'
import TabMenu from 'primevue/tabmenu'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import DotSpinner from '@/components/common/DotSpinner.vue'
import HoneyToast from '@/components/honeyToast/HoneyToast.vue'
import Button from '@/components/ui/button/Button.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { api } from '@/scripts/api'
import { useCommandStore } from '@/stores/commandStore'
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import { useComfyManagerStore } from '@/workbench/extensions/manager/stores/comfyManagerStore'
const { t } = useI18n()
const comfyManagerStore = useComfyManagerStore()
const settingStore = useSettingStore()
const { runFullConflictAnalysis } = useConflictDetection()
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const tabs = computed(() => [
{ label: t('manager.installationQueue') },
{
label: t('manager.failed', {
count: comfyManagerStore.failedTasksIds.length
})
}
])
const focusedLogs = computed(() => {
if (activeTabIndex.value === 0) {
return comfyManagerStore.succeededTasksLogs
}
return comfyManagerStore.failedTasksLogs
})
const visible = computed(() => comfyManagerStore.taskLogs.length > 0)
const isRestarting = ref(false)
const isRestartCompleted = ref(false)
const isInProgress = computed(
() => comfyManagerStore.isProcessingTasks || isRestarting.value
)
const isTaskInProgress = (index: number) => {
const log = focusedLogs.value[index]
if (!log) return false
const taskQueue = comfyManagerStore.taskQueue
if (!taskQueue) return false
const allQueueTasks = [
...(taskQueue.running_queue || []),
...(taskQueue.pending_queue || [])
]
return allQueueTasks.some((task) => task.ui_id === log.taskId)
}
const completedTasksCount = computed(() => {
return (
comfyManagerStore.succeededTasksIds.length +
comfyManagerStore.failedTasksIds.length
)
})
const totalTasksCount = computed(() => {
const completedTasks = Object.keys(comfyManagerStore.taskHistory).length
const taskQueue = comfyManagerStore.taskQueue
const queuedTasks = taskQueue
? (taskQueue.running_queue?.length || 0) +
(taskQueue.pending_queue?.length || 0)
: 0
return completedTasks + queuedTasks
})
const currentTaskName = computed(() => {
if (isRestarting.value) {
return t('manager.restartingBackend')
}
if (isRestartCompleted.value) {
return t('manager.extensionsSuccessfullyInstalled')
}
if (!comfyManagerStore.taskLogs.length)
return t('manager.installingDependencies')
const task = comfyManagerStore.taskLogs.at(-1)
return task?.taskName ?? t('manager.installingDependencies')
})
const collapsedPanels = ref<Record<number, boolean>>({})
function togglePanel(index: number) {
collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref<HTMLElement | null>(null)
const { y: scrollY } = useScroll(sectionsContainerRef, {
eventListenerOptions: { passive: true }
})
const lastPanelRef = ref<HTMLElement | null>(null)
const isUserScrolling = ref(false)
const lastPanelLogs = computed(() => focusedLogs.value?.at(-1)?.logs)
function isAtBottom(el: HTMLElement | null) {
if (!el) return false
const threshold = 20
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < threshold
}
function scrollLastPanelToBottom() {
if (!lastPanelRef.value || isUserScrolling.value) return
lastPanelRef.value.scrollTop = lastPanelRef.value.scrollHeight
}
function scrollContentToBottom() {
scrollY.value = sectionsContainerRef.value?.scrollHeight ?? 0
}
function resetUserScrolling() {
isUserScrolling.value = false
}
function handleScroll(e: Event) {
const target = e.target as HTMLElement
if (target !== lastPanelRef.value) return
isUserScrolling.value = !isAtBottom(target)
}
function onLogsAdded() {
if (isUserScrolling.value) return
scrollLastPanelToBottom()
}
whenever(lastPanelLogs, onLogsAdded, { flush: 'post', deep: true })
whenever(() => isExpanded.value, scrollContentToBottom)
whenever(() => !isExpanded.value, resetUserScrolling)
function closeToast() {
comfyManagerStore.resetTaskState()
isExpanded.value = false
}
async function handleRestart() {
const originalToastSetting = settingStore.get(
'Comfy.Toast.DisableReconnectingToast'
)
try {
await settingStore.set('Comfy.Toast.DisableReconnectingToast', true)
isRestarting.value = true
const onReconnect = async () => {
try {
comfyManagerStore.setStale()
await useCommandStore().execute('Comfy.RefreshNodeDefinitions')
await useWorkflowService().reloadCurrentWorkflow()
void runFullConflictAnalysis()
} finally {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = true
setTimeout(() => {
closeToast()
}, 3000)
}
}
useEventListener(api, 'reconnected', onReconnect, { once: true })
await useComfyManagerService().rebootComfyUI()
} catch (error) {
await settingStore.set(
'Comfy.Toast.DisableReconnectingToast',
originalToastSetting
)
isRestarting.value = false
isRestartCompleted.value = false
closeToast()
throw error
}
}
onMounted(() => {
scrollContentToBottom()
})
onBeforeUnmount(() => {
isExpanded.value = false
})
</script>
<template>
<HoneyToast v-model:expanded="isExpanded" :visible>
<template #default>
<div v-if="isExpanded" class="flex items-center px-4 py-2">
<TabMenu
v-model:active-index="activeTabIndex"
:model="tabs"
class="w-full border-none"
:pt="{
menu: { class: 'border-none' },
menuitem: { class: 'font-medium' },
action: { class: 'px-4 py-2' }
}"
/>
</div>
<div
ref="sectionsContainerRef"
class="scroll-container max-h-[450px] overflow-y-auto px-6 py-4"
:style="{
scrollbarWidth: 'thin',
scrollbarColor: 'rgba(156, 163, 175, 0.5) transparent'
}"
>
<div v-for="(log, index) in focusedLogs" :key="index">
<Panel
:expanded="collapsedPanels[index] === true"
toggleable
class="shadow-elevation-1 mt-2 rounded-lg"
>
<template #header>
<div class="flex w-full items-center justify-between py-2">
<div class="flex flex-col text-sm leading-normal font-medium">
<span>{{ log.taskName }}</span>
<span class="text-muted">
{{
isTaskInProgress(index)
? t('g.inProgress')
: t('g.completed') + ' ✓'
}}
</span>
</div>
</div>
</template>
<template #toggleicon>
<Button
variant="textonly"
class="text-neutral-300"
@click="togglePanel(index)"
>
<i
:class="
collapsedPanels[index]
? 'pi pi-chevron-right'
: 'pi pi-chevron-down'
"
/>
</Button>
</template>
<div
:ref="
index === focusedLogs.length - 1
? (el) => (lastPanelRef = el as HTMLElement)
: undefined
"
class="h-64 overflow-y-auto rounded-lg bg-black"
:class="{
'h-64': index !== focusedLogs.length - 1,
grow: index === focusedLogs.length - 1
}"
@scroll="handleScroll"
>
<div class="h-full">
<div
v-for="(logLine, logIndex) in log.logs"
:key="logIndex"
class="text-muted"
>
<pre class="break-words whitespace-pre-wrap">{{
logLine
}}</pre>
</div>
</div>
</div>
</Panel>
</div>
</div>
</template>
<template #footer="{ toggle }">
<div class="flex w-full items-center justify-between px-6 py-2 shadow-lg">
<div class="flex items-center text-base leading-none">
<div class="flex items-center">
<template v-if="isInProgress">
<DotSpinner duration="1s" class="mr-2" />
<span>{{ currentTaskName }}</span>
</template>
<template v-else-if="isRestartCompleted">
<span class="mr-2">🎉</span>
<span>{{ currentTaskName }}</span>
</template>
<template v-else>
<span class="mr-2"></span>
<span>{{ t('manager.restartToApplyChanges') }}</span>
</template>
</div>
</div>
<div class="flex items-center gap-4">
<span v-if="isInProgress" class="text-sm text-muted-foreground">
{{ completedTasksCount }} {{ t('g.progressCountOf') }}
{{ totalTasksCount }}
</span>
<div class="flex items-center">
<Button
v-if="!isInProgress && !isRestartCompleted"
variant="secondary"
class="mr-4 rounded-full border-2 border-base-foreground px-3 text-base-foreground hover:bg-secondary-background-hover"
@click="handleRestart"
>
{{ t('manager.applyChanges') }}
</Button>
<Button
v-else-if="!isRestartCompleted"
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="
t(isExpanded ? 'contextMenu.Collapse' : 'contextMenu.Expand')
"
@click.stop="toggle"
>
<i
:class="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
/>
</Button>
<Button
variant="muted-textonly"
size="sm"
class="rounded-full font-bold"
:aria-label="t('g.close')"
@click.stop="closeToast"
>
<i class="pi pi-times" />
</Button>
</div>
</div>
</div>
</template>
</HoneyToast>
</template>

View File

@@ -4,7 +4,6 @@ import type { Ref } from 'vue'
import { computed, ref } from 'vue'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
@@ -27,8 +26,6 @@ export const useManagerQueue = (
taskQueue: Ref<ManagerTaskQueue>,
installedPacks: Ref<Record<string, any>>
) => {
const { showManagerProgressDialog } = useDialogService()
// Task queue state (read-only from server)
const maxHistoryItems = ref(64)
const isLoading = ref(false)
@@ -113,15 +110,6 @@ export const useManagerQueue = (
(event: CustomEvent<ManagerWsTaskDoneMsg>) => {
if (event?.type === MANAGER_WS_TASK_DONE_NAME && event.detail?.state) {
updateTaskState(event.detail.state)
// If no more tasks are running/pending, hide the progress dialog
if (allTasksDone.value) {
setTimeout(() => {
if (allTasksDone.value) {
showManagerProgressDialog()
}
}, 1000) // Small delay to let users see completion
}
}
}
)
@@ -133,9 +121,6 @@ export const useManagerQueue = (
(event: CustomEvent<ManagerWsTaskStartedMsg>) => {
if (event?.type === MANAGER_WS_TASK_STARTED_NAME && event.detail?.state) {
updateTaskState(event.detail.state)
// Show progress dialog when a task starts
showManagerProgressDialog()
}
}
)

View File

@@ -8,7 +8,7 @@ import { useCachedRequest } from '@/composables/useCachedRequest'
import { useServerLogs } from '@/composables/useServerLogs'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { normalizePackKeys } from '@/utils/packUtils'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
@@ -32,7 +32,6 @@ type UpdateAllPacksParams = components['schemas']['UpdateAllPacksParams']
export const useComfyManagerStore = defineStore('comfyManager', () => {
const { t } = useI18n()
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set())
@@ -204,8 +203,6 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
})
try {
// Show progress dialog immediately when task is queued
showManagerProgressDialog()
managerQueue.isProcessing.value = true
// Prepare logging hook
@@ -392,44 +389,3 @@ export const useComfyManagerStore = defineStore('comfyManager', () => {
enablePack
}
})
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const activeTabIndex = ref(0)
const setActiveTabIndex = (index: number) => {
activeTabIndex.value = index
}
const getActiveTabIndex = () => {
return activeTabIndex.value
}
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand,
setActiveTabIndex,
getActiveTabIndex
}
}
)

View File

@@ -1,486 +0,0 @@
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import { useComfyManagerService } from '@/workbench/extensions/manager/services/comfyManagerService'
import {
useComfyManagerStore,
useManagerProgressDialogStore
} from '@/workbench/extensions/manager/stores/comfyManagerStore'
import type { TaskLog } from '@/workbench/extensions/manager/types/comfyManagerTypes'
// Mock modules
vi.mock('@/workbench/extensions/manager/stores/comfyManagerStore')
vi.mock('@/stores/dialogStore')
vi.mock('@/platform/settings/settingStore')
vi.mock('@/stores/commandStore')
vi.mock('@/workbench/extensions/manager/services/comfyManagerService')
vi.mock(
'@/workbench/extensions/manager/composables/useConflictDetection',
() => ({
useConflictDetection: vi.fn(() => ({
conflictedPackages: { value: [] },
runFullConflictAnalysis: vi.fn().mockResolvedValue(undefined)
}))
})
)
// Mock useEventListener to capture the event handler
let reconnectHandler: (() => void) | null = null
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual('@vueuse/core')
return {
...actual,
useEventListener: vi.fn(
(_target: any, event: string, handler: any, _options: any) => {
if (event === 'reconnected') {
reconnectHandler = handler
}
}
)
}
})
vi.mock('@/platform/workflow/core/services/workflowService', () => ({
useWorkflowService: vi.fn(() => ({
reloadCurrentWorkflow: vi.fn().mockResolvedValue(undefined)
}))
}))
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
useColorPaletteStore: vi.fn(() => ({
completedActivePalette: {
light_theme: false
}
}))
}))
// Helper function to mount component with required setup
const mountComponent = (options: { captureError?: boolean } = {}) => {
const pinia = createPinia()
setActivePinia(pinia)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: {
close: 'Close',
progressCountOf: 'of'
},
contextMenu: {
Collapse: 'Collapse',
Expand: 'Expand'
},
manager: {
clickToFinishSetup: 'Click',
applyChanges: 'Apply Changes',
toFinishSetup: 'to finish setup',
restartingBackend: 'Restarting backend to apply changes...',
extensionsSuccessfullyInstalled:
'Extension(s) successfully installed and are ready to use!',
restartToApplyChanges: 'To apply changes, please restart ComfyUI',
installingDependencies: 'Installing dependencies...'
}
}
}
})
const config: any = {
global: {
plugins: [pinia, PrimeVue, i18n]
}
}
// Add error handler for tests that expect errors
if (options.captureError) {
config.global.config = {
errorHandler: () => {
// Suppress error in test
}
}
}
return mount(ManagerProgressFooter, config)
}
describe('ManagerProgressFooter', () => {
const mockTaskLogs: TaskLog[] = []
const mockComfyManagerStore = {
taskLogs: mockTaskLogs,
allTasksDone: true,
isProcessingTasks: false,
succeededTasksIds: [] as string[],
failedTasksIds: [] as string[],
taskHistory: {} as Record<string, any>,
taskQueue: null,
resetTaskState: vi.fn(),
clearLogs: vi.fn(),
setStale: vi.fn(),
// Add other required properties
isLoading: { value: false },
error: { value: null },
statusMessage: { value: 'DONE' },
installedPacks: {},
installedPacksIds: new Set(),
isPackInstalled: vi.fn(),
isPackEnabled: vi.fn(),
getInstalledPackVersion: vi.fn(),
refreshInstalledList: vi.fn(),
installPack: vi.fn(),
uninstallPack: vi.fn(),
updatePack: vi.fn(),
updateAllPacks: vi.fn(),
disablePack: vi.fn(),
enablePack: vi.fn()
}
const mockDialogStore = {
closeDialog: vi.fn(),
// Add other required properties
dialogStack: { value: [] },
showDialog: vi.fn(),
$id: 'dialog',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockSettingStore = {
get: vi.fn().mockReturnValue(false),
set: vi.fn(),
// Add other required properties
settingValues: { value: {} },
settingsById: { value: {} },
exists: vi.fn(),
getDefaultValue: vi.fn(),
loadSettingValues: vi.fn(),
updateValue: vi.fn(),
$id: 'setting',
$state: {} as any,
$patch: vi.fn(),
$reset: vi.fn(),
$subscribe: vi.fn(),
$dispose: vi.fn(),
$onAction: vi.fn()
}
const mockProgressDialogStore = {
isExpanded: false,
toggle: vi.fn(),
collapse: vi.fn(),
expand: vi.fn()
}
const mockCommandStore = {
execute: vi.fn().mockResolvedValue(undefined)
}
const mockComfyManagerService = {
rebootComfyUI: vi.fn().mockResolvedValue(null)
}
beforeEach(() => {
vi.clearAllMocks()
// Create new pinia instance for each test
const pinia = createPinia()
setActivePinia(pinia)
// Reset task logs
mockTaskLogs.length = 0
mockComfyManagerStore.taskLogs = mockTaskLogs
// Reset event handler
reconnectHandler = null
vi.mocked(useComfyManagerStore).mockReturnValue(
mockComfyManagerStore as any
)
vi.mocked(useDialogStore).mockReturnValue(mockDialogStore as any)
vi.mocked(useSettingStore).mockReturnValue(mockSettingStore as any)
vi.mocked(useManagerProgressDialogStore).mockReturnValue(
mockProgressDialogStore as any
)
vi.mocked(useCommandStore).mockReturnValue(mockCommandStore as any)
vi.mocked(useComfyManagerService).mockReturnValue(
mockComfyManagerService as any
)
})
describe('State 1: Queue Running', () => {
it('should display loading spinner and progress counter when queue is running', async () => {
// Setup queue running state
mockComfyManagerStore.isProcessingTasks = true
mockComfyManagerStore.succeededTasksIds = ['1', '2']
mockComfyManagerStore.failedTasksIds = []
mockComfyManagerStore.taskHistory = {
'1': { taskName: 'Installing pack1' },
'2': { taskName: 'Installing pack2' },
'3': { taskName: 'Installing pack3' }
}
mockTaskLogs.push(
{ taskName: 'Installing pack1', taskId: '1', logs: [] },
{ taskName: 'Installing pack2', taskId: '2', logs: [] },
{ taskName: 'Installing pack3', taskId: '3', logs: [] }
)
const wrapper = mountComponent()
// Check loading spinner exists (DotSpinner component)
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check current task name is displayed
expect(wrapper.text()).toContain('Installing pack3')
// Check progress counter (completed: 2 of 3)
expect(wrapper.text()).toMatch(/2.*of.*3/)
// Check expand/collapse button exists
const expandButton = wrapper.find('[aria-label="Expand"]')
expect(expandButton.exists()).toBe(true)
// Check Apply Changes button is NOT shown
expect(wrapper.text()).not.toContain('Apply Changes')
})
it('should toggle expansion when expand button is clicked', async () => {
mockComfyManagerStore.isProcessingTasks = true
mockTaskLogs.push({ taskName: 'Installing', taskId: '1', logs: [] })
const wrapper = mountComponent()
const expandButton = wrapper.find('[aria-label="Expand"]')
await expandButton.trigger('click')
expect(mockProgressDialogStore.toggle).toHaveBeenCalled()
})
})
describe('State 2: Tasks Completed (Waiting for Restart)', () => {
it('should display check mark and Apply Changes button when all tasks are done', async () => {
// Setup tasks completed state
mockComfyManagerStore.isProcessingTasks = false
mockTaskLogs.push(
{ taskName: 'Installed pack1', taskId: '1', logs: [] },
{ taskName: 'Installed pack2', taskId: '2', logs: [] }
)
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Check check mark emoji
expect(wrapper.text()).toContain('✅')
// Check restart message
expect(wrapper.text()).toContain(
'To apply changes, please restart ComfyUI'
)
expect(wrapper.text()).toContain('Apply Changes')
// Check Apply Changes button exists
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// Check no progress counter
expect(wrapper.text()).not.toMatch(/\d+.*of.*\d+/)
})
})
describe('State 3: Restarting', () => {
it('should display restarting message and spinner during restart', async () => {
// Setup completed state first
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Click Apply Changes to trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for state update
await nextTick()
// Check restarting message
expect(wrapper.text()).toContain('Restarting backend to apply changes...')
// Check loading spinner during restart
expect(wrapper.find('.inline-flex').exists()).toBe(true)
// Check Apply Changes button is hidden
expect(wrapper.text()).not.toContain('Apply Changes')
})
})
describe('State 4: Restart Completed', () => {
it('should display success message and auto-close after 3 seconds', async () => {
vi.useFakeTimers()
// Setup completed state
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
const wrapper = mountComponent()
// Trigger restart
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for restart completed state
await nextTick()
// Check success message
expect(wrapper.text()).toContain('🎉')
expect(wrapper.text()).toContain(
'Extension(s) successfully installed and are ready to use!'
)
// Check dialog closes after 3 seconds
vi.advanceTimersByTime(3000)
await nextTick()
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
expect(mockComfyManagerStore.resetTaskState).toHaveBeenCalled()
vi.useRealTimers()
})
})
describe('Common Features', () => {
it('should always display close button', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
expect(closeButton.exists()).toBe(true)
})
it('should close dialog when close button is clicked', async () => {
const wrapper = mountComponent()
const closeButton = wrapper.find('[aria-label="Close"]')
await closeButton.trigger('click')
expect(mockDialogStore.closeDialog).toHaveBeenCalledWith({
key: 'global-manager-progress-dialog'
})
})
})
describe('Toast Management', () => {
it('should suppress reconnection toasts during restart', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Check toast setting was disabled
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
true
)
})
it('should restore toast settings after restart completes', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
mockSettingStore.get.mockReturnValue(false) // Original setting
const wrapper = mountComponent()
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
await applyButton?.trigger('click')
// Wait for event listener to be set up
await nextTick()
// Trigger the reconnect handler directly
if (reconnectHandler) {
await reconnectHandler()
}
// Wait for settings restoration
await nextTick()
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false // Restored to original
)
})
})
describe('Error Handling', () => {
it('should restore state and close dialog on restart error', async () => {
mockComfyManagerStore.isProcessingTasks = false
mockComfyManagerStore.allTasksDone = true
// Mock restart to throw error
mockComfyManagerService.rebootComfyUI.mockRejectedValue(
new Error('Restart failed')
)
const wrapper = mountComponent({ captureError: true })
// Click Apply Changes
const applyButton = wrapper
.findAll('button')
.find((btn) => btn.text().includes('Apply Changes'))
expect(applyButton).toBeTruthy()
// The component throws the error but Vue Test Utils catches it
// We need to check if the error handling logic was executed
await applyButton!.trigger('click').catch(() => {
// Error is expected, ignore it
})
// Wait for error handling
await nextTick()
// Check dialog was closed on error
expect(mockDialogStore.closeDialog).toHaveBeenCalled()
// Check toast settings were restored
expect(mockSettingStore.set).toHaveBeenCalledWith(
'Comfy.Toast.DisableReconnectingToast',
false
)
// Check that the error handler was called
expect(mockComfyManagerService.rebootComfyUI).toHaveBeenCalled()
})
})
})

View File

@@ -4,13 +4,6 @@ import { ref } from 'vue'
import { useManagerQueue } from '@/workbench/extensions/manager/composables/useManagerQueue'
import type { components } from '@/workbench/extensions/manager/types/generatedManagerTypes'
// Mock dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
// Mock the app API
vi.mock('@/scripts/app', () => ({
app: {

View File

@@ -17,12 +17,6 @@ vi.mock('@/workbench/extensions/manager/services/comfyManagerService', () => ({
useComfyManagerService: vi.fn()
}))
vi.mock('@/services/dialogService', () => ({
useDialogService: () => ({
showManagerProgressDialog: vi.fn()
})
}))
vi.mock('@/workbench/extensions/manager/composables/useManagerQueue', () => {
const enqueueTaskMock = vi.fn()