feat: split job history into a dedicated sidebar tab (#8957)

## Summary

Move queue job history into a dedicated sidebar tab (gated by `Comfy.Queue.QPOV2`) and remove mixed job-history UI from the Assets sidebar so assets and job controls are separated.

## Changes

- **What**:
  - Added `JobHistorySidebarTab` with reusable job UI primitives: `JobFilterTabs`, `JobFilterActions`, `JobAssetsList`, and shared `JobHistoryActionsMenu`.
  - Added reactive `job-history` tab registration in `sidebarTabStore`; prepends above Assets when `Comfy.Queue.QPOV2` is enabled and unregisters cleanly when disabled.
  - Added debounced search to `useJobList` (filters by job title, metadata, and prompt id).
  - Extracted clear-history dialog logic to `useQueueClearHistoryDialog` and reused it from queue overlay and job history tab.
  - Removed active-job rendering and queue-clear controls from assets list/grid/tab views; assets sidebar now focuses on media assets only.
  - Removed the QPOV2 gate from `MediaAssetViewModeToggle` and updated queue/job localized copy.
  - Added and updated tests for queue overlay header actions, job filters, search filtering, sidebar tab registration, and assets sidebar behavior.

## Review Focus

- Verify QPOV2 toggle behavior:
  - `Docked Job History` menu action toggles `Comfy.Queue.QPOV2`.
  - `job-history` tab insertion/removal order and active-tab reset on removal.
- Verify behavior split between tabs:
  - Job controls (cancel/delete/view/filter/search/clear history/clear queue) live in Job History.
  - Assets sidebar loading/empty states and list/grid rendering remain correct after removing active jobs.

## Screenshots (if applicable)
<img width="670" height="707" alt="image" src="https://github.com/user-attachments/assets/3a201fcb-d104-4e95-b5fe-49c4006a30a5" />
This commit is contained in:
Benjamin Lu
2026-02-20 16:42:41 -08:00
committed by GitHub
parent 7baa14af86
commit b3aed9afd0
27 changed files with 1194 additions and 662 deletions

View File

@@ -421,6 +421,52 @@ describe('useJobList', () => {
expect(instance.filteredTasks.value.map((t) => t.jobId)).toEqual(['wf-1'])
})
it('filters jobs by search query', async () => {
vi.useFakeTimers()
queueStoreMock.historyTasks = [
createTask({
jobId: 'alpha',
queueIndex: 2,
mockState: 'completed',
createTime: 2000,
executionEndTimestamp: 2000
}),
createTask({
jobId: 'beta',
queueIndex: 1,
mockState: 'failed',
createTime: 1000,
executionEndTimestamp: 1000
})
]
const instance = initComposable()
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'alpha',
'beta'
])
instance.searchQuery.value = 'beta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'failed meta'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value.map((task) => task.jobId)).toEqual([
'beta'
])
instance.searchQuery.value = 'does-not-exist'
await vi.advanceTimersByTimeAsync(200)
await flush()
expect(instance.filteredTasks.value).toEqual([])
})
it('hydrates job items with active progress and compute hours', async () => {
queueStoreMock.runningTasks = [
createTask({

View File

@@ -1,3 +1,4 @@
import { refDebounced } from '@vueuse/core'
import { orderBy } from 'es-toolkit/array'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -197,6 +198,8 @@ export function useJobList() {
const selectedJobTab = ref<JobTab>('All')
const selectedWorkflowFilter = ref<'all' | 'current'>('all')
const selectedSortMode = ref<JobSortMode>('mostRecent')
const searchQuery = ref('')
const debouncedSearchQuery = refDebounced(searchQuery, 150)
const mostRecentTimestamp = (task: TaskItemImpl) => task.createTime ?? 0
@@ -248,8 +251,12 @@ export function useJobList() {
return entries
})
const normalizedSearchQuery = computed(() =>
debouncedSearchQuery.value.trim().toLocaleLowerCase()
)
const filteredTasks = computed<TaskItemImpl[]>(() =>
filteredTaskEntries.value.map(({ task }) => task)
searchableTaskEntries.value.map(({ task }) => task)
)
const jobItems = computed<JobListItem[]>(() => {
@@ -307,11 +314,31 @@ export function useJobList() {
return m
})
const searchableTaskEntries = computed<TaskWithState[]>(() => {
if (!normalizedSearchQuery.value) return filteredTaskEntries.value
return filteredTaskEntries.value.filter(({ task }) => {
const taskId = String(task.jobId ?? '').toLocaleLowerCase()
const item = jobItemById.value.get(String(task.jobId))
if (!item) {
return taskId.includes(normalizedSearchQuery.value)
}
const title = item.title.toLocaleLowerCase()
const meta = item.meta.toLocaleLowerCase()
return (
title.includes(normalizedSearchQuery.value) ||
meta.includes(normalizedSearchQuery.value) ||
taskId.includes(normalizedSearchQuery.value)
)
})
})
const groupedJobItems = computed<JobGroup[]>(() => {
const groups: JobGroup[] = []
const index = new Map<string, number>()
const localeValue = locale.value
for (const { task, state } of filteredTaskEntries.value) {
for (const { task, state } of searchableTaskEntries.value) {
let ts: number | undefined
if (state === 'completed' || state === 'failed') {
ts = task.executionEndTimestamp
@@ -356,6 +383,7 @@ export function useJobList() {
selectedJobTab,
selectedWorkflowFilter,
selectedSortMode,
searchQuery,
hasFailedJobs,
// data sources
allTasksSorted,

View File

@@ -0,0 +1,32 @@
import QueueClearHistoryDialog from '@/components/queue/dialogs/QueueClearHistoryDialog.vue'
import { useDialogStore } from '@/stores/dialogStore'
export const useQueueClearHistoryDialog = () => {
const dialogStore = useDialogStore()
const showQueueClearHistoryDialog = () => {
dialogStore.showDialog({
key: 'queue-clear-history',
component: QueueClearHistoryDialog,
dialogComponentProps: {
headless: true,
closable: false,
closeOnEscape: true,
dismissableMask: true,
pt: {
root: {
class: 'max-w-90 w-auto bg-transparent border-none shadow-none'
},
content: {
class: 'bg-transparent',
style: 'padding: 0'
}
}
}
})
}
return {
showQueueClearHistoryDialog
}
}

View File

@@ -0,0 +1,16 @@
import { markRaw } from 'vue'
import JobHistorySidebarTab from '@/components/sidebar/tabs/JobHistorySidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useJobHistorySidebarTab = (): SidebarTabExtension => {
return {
id: 'job-history',
icon: 'icon-[lucide--history]',
title: 'queue.jobHistory',
tooltip: 'queue.jobHistory',
label: 'queue.jobHistory',
component: markRaw(JobHistorySidebarTab),
type: 'vue'
}
}