feat: add job history and assets sidebar badge behavior (#9050)

## 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 <action@github.com>
This commit is contained in:
Benjamin Lu
2026-02-22 01:05:39 -08:00
committed by GitHub
parent a82c984520
commit 35f15d18b4
8 changed files with 386 additions and 15 deletions

View File

@@ -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()

View File

@@ -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
}
}

View File

@@ -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()
})
})

View File

@@ -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
}
}
}

View File

@@ -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)
})
})

View File

@@ -479,6 +479,7 @@ export const useQueueStore = defineStore('queue', () => {
const runningTasks = shallowRef<TaskItemImpl[]>([])
const pendingTasks = shallowRef<TaskItemImpl[]>([])
const historyTasks = shallowRef<TaskItemImpl[]>([])
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,

View File

@@ -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)
})
})

View File

@@ -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<string, number>())
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<string, number>()
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
}
}
)