+ index === taskPanels.value.length - 1 && managerStore.uncompletedCount > 0
const taskPanels = computed(() => taskLogs)
const isExpanded = computed(() => progressDialogContent.isExpanded)
+const isCollapsed = computed(() => !isExpanded.value)
-const expandedPanels = ref>({})
+const collapsedPanels = ref>({})
const togglePanel = (index: number) => {
- expandedPanels.value[index] = !expandedPanels.value[index]
+ collapsedPanels.value[index] = !collapsedPanels.value[index]
}
const sectionsContainerRef = ref(null)
-const { y: scrollY } = useScroll(sectionsContainerRef)
+const { y: scrollY } = useScroll(sectionsContainerRef, {
+ eventListenerOptions: {
+ passive: true
+ }
+})
-const scrollToBottom = () => {
+const lastPanelRef = ref(null)
+const isUserScrolling = ref(false)
+const lastPanelLogs = computed(() => taskPanels.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
}
-whenever(() => isExpanded.value, scrollToBottom)
+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(() => {
- expandedPanels.value = {}
- scrollToBottom()
+ scrollContentToBottom()
})
onBeforeUnmount(() => {
diff --git a/src/components/dialog/content/__tests__/ManagerProgressDialogContent.test.ts b/src/components/dialog/content/__tests__/ManagerProgressDialogContent.test.ts
new file mode 100644
index 000000000..d985c7465
--- /dev/null
+++ b/src/components/dialog/content/__tests__/ManagerProgressDialogContent.test.ts
@@ -0,0 +1,176 @@
+import { VueWrapper, mount } from '@vue/test-utils'
+import { createPinia } from 'pinia'
+import Button from 'primevue/button'
+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'
+
+import ManagerProgressDialogContent from '../ManagerProgressDialogContent.vue'
+
+type ComponentInstance = InstanceType & {
+ lastPanelRef: HTMLElement | null
+ onLogsAdded: () => void
+ handleScroll: (e: { target: HTMLElement }) => void
+ isUserScrolling: boolean
+ resetUserScrolling: () => void
+ collapsedPanels: Record
+ 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('@/stores/comfyManagerStore', () => ({
+ useComfyManagerStore: vi.fn(() => ({
+ taskLogs: [...defaultMockTaskLogs]
+ })),
+ useManagerProgressDialogStore: vi.fn(() => ({
+ isExpanded: true,
+ collapse: mockCollapse
+ }))
+}))
+
+describe('ManagerProgressDialogContent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockCollapse.mockReset()
+ })
+
+ const mountComponent = ({
+ props = {}
+ }: Record = {}): VueWrapper => {
+ 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
+ }
+
+ 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()
+ })
+})
diff --git a/src/locales/en/main.json b/src/locales/en/main.json
index daf44af9e..dd38864b8 100644
--- a/src/locales/en/main.json
+++ b/src/locales/en/main.json
@@ -96,7 +96,9 @@
"enabled": "Enabled",
"installed": "Installed",
"restart": "Restart",
- "missing": "Missing"
+ "missing": "Missing",
+ "inProgress": "In progress",
+ "completed": "Completed"
},
"manager": {
"title": "Custom Nodes Manager",
diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json
index 57fe0ce24..9e1d9a6c7 100644
--- a/src/locales/fr/main.json
+++ b/src/locales/fr/main.json
@@ -128,6 +128,7 @@
"comingSoon": "Bientôt disponible",
"command": "Commande",
"community": "Communauté",
+ "completed": "Terminé",
"confirm": "Confirmer",
"continue": "Continuer",
"control_after_generate": "contrôle après génération",
@@ -157,6 +158,7 @@
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"import": "Importer",
+ "inProgress": "En cours",
"insert": "Insérer",
"install": "Installer",
"installed": "Installé",
diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json
index 6542fc86b..d1561b444 100644
--- a/src/locales/ja/main.json
+++ b/src/locales/ja/main.json
@@ -128,6 +128,7 @@
"comingSoon": "近日公開",
"command": "コマンド",
"community": "コミュニティ",
+ "completed": "完了",
"confirm": "確認",
"continue": "続ける",
"control_after_generate": "生成後の制御",
@@ -157,6 +158,7 @@
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"import": "インポート",
+ "inProgress": "進行中",
"insert": "挿入",
"install": "インストール",
"installed": "インストール済み",
diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json
index 01be2bca9..511f4753b 100644
--- a/src/locales/ko/main.json
+++ b/src/locales/ko/main.json
@@ -128,6 +128,7 @@
"comingSoon": "곧 출시 예정",
"command": "명령",
"community": "커뮤니티",
+ "completed": "완료됨",
"confirm": "확인",
"continue": "계속",
"control_after_generate": "생성 후 제어",
@@ -157,6 +158,7 @@
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"import": "가져오기",
+ "inProgress": "진행 중",
"insert": "삽입",
"install": "설치",
"installed": "설치됨",
diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json
index c45b68751..e035a2d2a 100644
--- a/src/locales/ru/main.json
+++ b/src/locales/ru/main.json
@@ -128,6 +128,7 @@
"comingSoon": "Скоро будет",
"command": "Команда",
"community": "Сообщество",
+ "completed": "Завершено",
"confirm": "Подтвердить",
"continue": "Продолжить",
"control_after_generate": "управление после генерации",
@@ -157,6 +158,7 @@
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"import": "Импорт",
+ "inProgress": "В процессе",
"insert": "Вставить",
"install": "Установить",
"installed": "Установлено",
diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json
index dc57e5d2a..f72dd3295 100644
--- a/src/locales/zh/main.json
+++ b/src/locales/zh/main.json
@@ -128,6 +128,7 @@
"comingSoon": "即将推出",
"command": "指令",
"community": "社区",
+ "completed": "已完成",
"confirm": "确认",
"continue": "继续",
"control_after_generate": "生成后控制",
@@ -157,6 +158,7 @@
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"import": "导入",
+ "inProgress": "进行中",
"insert": "插入",
"install": "安装",
"installed": "已安装",