From 35f15d18b4cb8a9d4c2beb2567af21be8e26164d Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Sun, 22 Feb 2026 01:05:39 -0800 Subject: [PATCH] feat: add job history and assets sidebar badge behavior (#9050) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Add sidebar badge behavior for queue/asset visibility updates: - Job History tab icon shows active jobs count (`queued + running`) only when the Job History panel is closed. - Assets tab icon no longer mirrors active jobs; when QPO V2 is enabled it now shows the number of assets added since the last time Assets was opened. - Opening Assets clears the unseen added-assets badge count. ## Changes - Added `iconBadge` logic to Job History sidebar tab. - Replaced Assets sidebar badge source with new unseen-assets counter logic. - Added `assetsSidebarBadgeStore` to track unseen asset additions from history updates and reset on Assets open. - Added/updated unit tests for both sidebar tab composables and the new store behavior. https://github.com/user-attachments/assets/33588a2a-c607-4fcc-8221-e7f11c3d79cc ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9050-fix-add-job-history-and-assets-sidebar-badge-behavior-30e6d73d365081c38297fe6aac9cd34c) by [Unito](https://www.unito.io) --------- Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com> Co-authored-by: GitHub Action --- .../sidebarTabs/useAssetsSidebarTab.test.ts | 20 +- .../sidebarTabs/useAssetsSidebarTab.ts | 6 +- .../useJobHistorySidebarTab.test.ts | 59 ++++++ .../sidebarTabs/useJobHistorySidebarTab.ts | 14 +- src/stores/queueStore.test.ts | 3 + src/stores/queueStore.ts | 13 +- .../workspace/assetsSidebarBadgeStore.test.ts | 187 ++++++++++++++++++ .../workspace/assetsSidebarBadgeStore.ts | 99 ++++++++++ 8 files changed, 386 insertions(+), 15 deletions(-) create mode 100644 src/composables/sidebarTabs/useJobHistorySidebarTab.test.ts create mode 100644 src/stores/workspace/assetsSidebarBadgeStore.test.ts create mode 100644 src/stores/workspace/assetsSidebarBadgeStore.ts diff --git a/src/composables/sidebarTabs/useAssetsSidebarTab.test.ts b/src/composables/sidebarTabs/useAssetsSidebarTab.test.ts index 1323f86df..b782efa8e 100644 --- a/src/composables/sidebarTabs/useAssetsSidebarTab.test.ts +++ b/src/composables/sidebarTabs/useAssetsSidebarTab.test.ts @@ -2,9 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { useAssetsSidebarTab } from '@/composables/sidebarTabs/useAssetsSidebarTab' -const { mockGetSetting, mockActiveJobsCount } = vi.hoisted(() => ({ +const { mockGetSetting, mockUnseenAddedAssetsCount } = vi.hoisted(() => ({ mockGetSetting: vi.fn(), - mockActiveJobsCount: { value: 0 } + mockUnseenAddedAssetsCount: { value: 0 } })) vi.mock('@/platform/settings/settingStore', () => ({ @@ -17,16 +17,16 @@ vi.mock('@/components/sidebar/tabs/AssetsSidebarTab.vue', () => ({ default: {} })) -vi.mock('@/stores/queueStore', () => ({ - useQueueStore: () => ({ - activeJobsCount: mockActiveJobsCount.value +vi.mock('@/stores/workspace/assetsSidebarBadgeStore', () => ({ + useAssetsSidebarBadgeStore: () => ({ + unseenAddedAssetsCount: mockUnseenAddedAssetsCount.value }) })) describe('useAssetsSidebarTab', () => { it('hides icon badge when QPO V2 is disabled', () => { mockGetSetting.mockReturnValue(false) - mockActiveJobsCount.value = 3 + mockUnseenAddedAssetsCount.value = 3 const sidebarTab = useAssetsSidebarTab() @@ -34,9 +34,9 @@ describe('useAssetsSidebarTab', () => { expect((sidebarTab.iconBadge as () => string | null)()).toBeNull() }) - it('shows active job count when QPO V2 is enabled', () => { + it('shows unseen added assets count when QPO V2 is enabled', () => { mockGetSetting.mockReturnValue(true) - mockActiveJobsCount.value = 3 + mockUnseenAddedAssetsCount.value = 3 const sidebarTab = useAssetsSidebarTab() @@ -44,9 +44,9 @@ describe('useAssetsSidebarTab', () => { expect((sidebarTab.iconBadge as () => string | null)()).toBe('3') }) - it('hides badge when no active jobs', () => { + it('hides badge when there are no unseen added assets', () => { mockGetSetting.mockReturnValue(true) - mockActiveJobsCount.value = 0 + mockUnseenAddedAssetsCount.value = 0 const sidebarTab = useAssetsSidebarTab() diff --git a/src/composables/sidebarTabs/useAssetsSidebarTab.ts b/src/composables/sidebarTabs/useAssetsSidebarTab.ts index 815d8c54e..a8cf23404 100644 --- a/src/composables/sidebarTabs/useAssetsSidebarTab.ts +++ b/src/composables/sidebarTabs/useAssetsSidebarTab.ts @@ -2,7 +2,7 @@ import { markRaw } from 'vue' import AssetsSidebarTab from '@/components/sidebar/tabs/AssetsSidebarTab.vue' import { useSettingStore } from '@/platform/settings/settingStore' -import { useQueueStore } from '@/stores/queueStore' +import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore' import type { SidebarTabExtension } from '@/types/extensionTypes' export const useAssetsSidebarTab = (): SidebarTabExtension => { @@ -21,8 +21,8 @@ export const useAssetsSidebarTab = (): SidebarTabExtension => { return null } - const queueStore = useQueueStore() - const count = queueStore.activeJobsCount + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + const count = assetsSidebarBadgeStore.unseenAddedAssetsCount return count > 0 ? count.toString() : null } } diff --git a/src/composables/sidebarTabs/useJobHistorySidebarTab.test.ts b/src/composables/sidebarTabs/useJobHistorySidebarTab.test.ts new file mode 100644 index 000000000..0b89eb563 --- /dev/null +++ b/src/composables/sidebarTabs/useJobHistorySidebarTab.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useJobHistorySidebarTab } from '@/composables/sidebarTabs/useJobHistorySidebarTab' + +const { mockActiveJobsCount, mockActiveSidebarTabId } = vi.hoisted(() => ({ + mockActiveJobsCount: { value: 0 }, + mockActiveSidebarTabId: { value: null as string | null } +})) + +vi.mock('@/components/sidebar/tabs/JobHistorySidebarTab.vue', () => ({ + default: {} +})) + +vi.mock('@/stores/queueStore', () => ({ + useQueueStore: () => ({ + activeJobsCount: mockActiveJobsCount.value + }) +})) + +vi.mock('@/stores/workspace/sidebarTabStore', () => ({ + useSidebarTabStore: () => ({ + activeSidebarTabId: mockActiveSidebarTabId.value + }) +})) + +describe('useJobHistorySidebarTab', () => { + beforeEach(() => { + mockActiveSidebarTabId.value = null + mockActiveJobsCount.value = 0 + }) + + it('shows active jobs count while the panel is closed', () => { + mockActiveSidebarTabId.value = 'assets' + mockActiveJobsCount.value = 3 + + const sidebarTab = useJobHistorySidebarTab() + + expect(typeof sidebarTab.iconBadge).toBe('function') + expect((sidebarTab.iconBadge as () => string | null)()).toBe('3') + }) + + it('hides badge while the job history panel is open', () => { + mockActiveSidebarTabId.value = 'job-history' + mockActiveJobsCount.value = 3 + + const sidebarTab = useJobHistorySidebarTab() + + expect((sidebarTab.iconBadge as () => string | null)()).toBeNull() + }) + + it('hides badge when there are no active jobs', () => { + mockActiveSidebarTabId.value = null + mockActiveJobsCount.value = 0 + + const sidebarTab = useJobHistorySidebarTab() + + expect((sidebarTab.iconBadge as () => string | null)()).toBeNull() + }) +}) diff --git a/src/composables/sidebarTabs/useJobHistorySidebarTab.ts b/src/composables/sidebarTabs/useJobHistorySidebarTab.ts index 3cee68fb3..0ac800851 100644 --- a/src/composables/sidebarTabs/useJobHistorySidebarTab.ts +++ b/src/composables/sidebarTabs/useJobHistorySidebarTab.ts @@ -1,6 +1,8 @@ import { markRaw } from 'vue' import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue' +import { useQueueStore } from '@/stores/queueStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' import type { SidebarTabExtension } from '@/types/extensionTypes' export const useJobHistorySidebarTab = (): SidebarTabExtension => { @@ -11,6 +13,16 @@ export const useJobHistorySidebarTab = (): SidebarTabExtension => { tooltip: 'queue.jobHistory', label: 'queue.jobHistory', component: markRaw(JobHistorySidebarTab), - type: 'vue' + type: 'vue', + iconBadge: () => { + const sidebarTabStore = useSidebarTabStore() + if (sidebarTabStore.activeSidebarTabId === 'job-history') { + return null + } + + const queueStore = useQueueStore() + const count = queueStore.activeJobsCount + return count > 0 ? count.toString() : null + } } } diff --git a/src/stores/queueStore.test.ts b/src/stores/queueStore.test.ts index ed68f7ec4..e8618d15c 100644 --- a/src/stores/queueStore.test.ts +++ b/src/stores/queueStore.test.ts @@ -535,6 +535,7 @@ describe('useQueueStore', () => { await store.update() const initialTask = store.historyTasks[0] + const initialHistoryTasks = store.historyTasks // Same job with same outputs_count mockGetHistory.mockResolvedValue([{ ...job }]) @@ -543,6 +544,8 @@ describe('useQueueStore', () => { // Should reuse the same instance expect(store.historyTasks[0]).toBe(initialTask) + // Should preserve array identity when history is unchanged + expect(store.historyTasks).toBe(initialHistoryTasks) }) }) diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index 3b1140591..c155057a8 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -479,6 +479,7 @@ export const useQueueStore = defineStore('queue', () => { const runningTasks = shallowRef([]) const pendingTasks = shallowRef([]) const historyTasks = shallowRef([]) + const hasFetchedHistorySnapshot = ref(false) const maxHistoryItems = ref(64) const isLoading = ref(false) @@ -557,7 +558,7 @@ export const useQueueStore = defineStore('queue', () => { currentHistory.map((impl) => [impl.jobId, impl]) ) - historyTasks.value = sortedHistory.map((job) => { + const nextHistoryTasks = sortedHistory.map((job) => { const existing = existingByJobId.get(job.id) if (!existing) return new TaskItemImpl(job) // Recreate if outputs_count changed to ensure lazy loading works @@ -566,6 +567,15 @@ export const useQueueStore = defineStore('queue', () => { } return existing }) + + const isHistoryUnchanged = + nextHistoryTasks.length === currentHistory.length && + nextHistoryTasks.every((task, index) => task === currentHistory[index]) + + if (!isHistoryUnchanged) { + historyTasks.value = nextHistoryTasks + } + hasFetchedHistorySnapshot.value = true } finally { // Only clear loading if this is the latest request. // A stale request completing (success or error) should not touch loading state @@ -595,6 +605,7 @@ export const useQueueStore = defineStore('queue', () => { runningTasks, pendingTasks, historyTasks, + hasFetchedHistorySnapshot, maxHistoryItems, isLoading, diff --git a/src/stores/workspace/assetsSidebarBadgeStore.test.ts b/src/stores/workspace/assetsSidebarBadgeStore.test.ts new file mode 100644 index 000000000..824b99924 --- /dev/null +++ b/src/stores/workspace/assetsSidebarBadgeStore.test.ts @@ -0,0 +1,187 @@ +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' +import { nextTick } from 'vue' +import { beforeEach, describe, expect, it } from 'vitest' + +import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import { TaskItemImpl, useQueueStore } from '@/stores/queueStore' +import { useAssetsSidebarBadgeStore } from '@/stores/workspace/assetsSidebarBadgeStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' + +const createHistoryTask = ({ + id, + outputsCount, + hasPreview = true +}: { + id: string + outputsCount?: number + hasPreview?: boolean +}) => + new TaskItemImpl({ + id, + status: 'completed', + create_time: Date.now(), + priority: 1, + outputs_count: outputsCount, + preview_output: hasPreview + ? { + filename: `${id}.png`, + subfolder: '', + type: 'output', + nodeId: '1', + mediaType: 'images' + } + : undefined + } as JobListItem) + +describe('useAssetsSidebarBadgeStore', () => { + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + }) + + it('does not count initial fetched history when store starts before hydration', async () => { + const queueStore = useQueueStore() + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 2 }) + ] + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.hasFetchedHistorySnapshot = true + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + }) + + it('counts new history items after baseline hydration while assets tab is closed', async () => { + const queueStore = useQueueStore() + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 2 }) + ] + queueStore.hasFetchedHistorySnapshot = true + + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-2', hasPreview: true }), + ...queueStore.historyTasks + ] + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1) + }) + + it('does not count preview fallback when server outputsCount is zero', async () => { + const queueStore = useQueueStore() + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 2 }) + ] + queueStore.hasFetchedHistorySnapshot = true + + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-2', outputsCount: 0, hasPreview: true }), + ...queueStore.historyTasks + ] + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + }) + + it('adds only delta when a seen job gains more outputs', async () => { + const queueStore = useQueueStore() + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', hasPreview: true }) + ] + queueStore.hasFetchedHistorySnapshot = true + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 3 }) + ] + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2) + }) + + it('treats a reappearing job as unseen after it aged out of history', async () => { + const queueStore = useQueueStore() + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 2 }) + ] + queueStore.hasFetchedHistorySnapshot = true + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-2', outputsCount: 1 }) + ] + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 1 }), + createHistoryTask({ id: 'job-2', outputsCount: 1 }) + ] + await nextTick() + + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(2) + }) + + it('clears and suppresses count while assets tab is open', async () => { + const queueStore = useQueueStore() + const sidebarTabStore = useSidebarTabStore() + const assetsSidebarBadgeStore = useAssetsSidebarBadgeStore() + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-1', outputsCount: 2 }) + ] + queueStore.hasFetchedHistorySnapshot = true + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-2', outputsCount: 4 }), + ...queueStore.historyTasks + ] + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(4) + + sidebarTabStore.activeSidebarTabId = 'assets' + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-3', outputsCount: 4 }), + ...queueStore.historyTasks + ] + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(0) + + sidebarTabStore.activeSidebarTabId = 'node-library' + await nextTick() + + queueStore.historyTasks = [ + createHistoryTask({ id: 'job-4', outputsCount: 1 }), + ...queueStore.historyTasks + ] + await nextTick() + expect(assetsSidebarBadgeStore.unseenAddedAssetsCount).toBe(1) + }) +}) diff --git a/src/stores/workspace/assetsSidebarBadgeStore.ts b/src/stores/workspace/assetsSidebarBadgeStore.ts new file mode 100644 index 000000000..97cfc9023 --- /dev/null +++ b/src/stores/workspace/assetsSidebarBadgeStore.ts @@ -0,0 +1,99 @@ +import { defineStore } from 'pinia' +import { ref, watch } from 'vue' + +import type { TaskItemImpl } from '@/stores/queueStore' +import { useQueueStore } from '@/stores/queueStore' +import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore' + +const getAddedAssetCount = (task: TaskItemImpl): number => { + if (typeof task.outputsCount === 'number') { + return Math.max(task.outputsCount, 0) + } + + return task.previewOutput ? 1 : 0 +} + +export const useAssetsSidebarBadgeStore = defineStore( + 'assetsSidebarBadge', + () => { + const queueStore = useQueueStore() + const sidebarTabStore = useSidebarTabStore() + + const unseenAddedAssetsCount = ref(0) + const countedHistoryAssetsByJobId = ref(new Map()) + const hasInitializedHistory = ref(false) + + const markCurrentHistoryAsSeen = () => { + countedHistoryAssetsByJobId.value = new Map( + queueStore.historyTasks.map((task) => [ + task.jobId, + getAddedAssetCount(task) + ]) + ) + } + + watch( + () => + [ + queueStore.historyTasks, + queueStore.hasFetchedHistorySnapshot + ] as const, + ([historyTasks, hasFetchedHistorySnapshot]) => { + if (!hasFetchedHistorySnapshot) { + return + } + + if (!hasInitializedHistory.value) { + hasInitializedHistory.value = true + markCurrentHistoryAsSeen() + return + } + + const isAssetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets' + const previousCountedAssetsByJobId = countedHistoryAssetsByJobId.value + const nextCountedAssetsByJobId = new Map() + + for (const task of historyTasks) { + const jobId = task.jobId + if (!jobId) { + continue + } + + const countedAssets = previousCountedAssetsByJobId.get(jobId) ?? 0 + const currentAssets = getAddedAssetCount(task) + const hasSeenJob = previousCountedAssetsByJobId.has(jobId) + + if (!isAssetsTabOpen && !hasSeenJob) { + unseenAddedAssetsCount.value += currentAssets + } else if (!isAssetsTabOpen && currentAssets > countedAssets) { + unseenAddedAssetsCount.value += currentAssets - countedAssets + } + + nextCountedAssetsByJobId.set( + jobId, + Math.max(countedAssets, currentAssets) + ) + } + + countedHistoryAssetsByJobId.value = nextCountedAssetsByJobId + }, + { immediate: true } + ) + + watch( + () => sidebarTabStore.activeSidebarTabId, + (activeSidebarTabId) => { + if (activeSidebarTabId !== 'assets') { + return + } + + unseenAddedAssetsCount.value = 0 + markCurrentHistoryAsSeen() + } + ) + + return { + unseenAddedAssetsCount + } + } +)