Compare commits

...

10 Commits

Author SHA1 Message Date
Benjamin Lu
6c0d7e72ec fix: trim virtual row helper exports 2026-03-26 23:43:33 -07:00
Benjamin Lu
cf697a4f60 test: cover job history virtual row sizing 2026-03-26 23:42:36 -07:00
Benjamin Lu
59c04795be Remove useless h-full 2026-03-26 23:21:34 -07:00
Benjamin Lu
daa8782f88 fix: forward job list attrs to scroll container 2026-03-26 22:11:47 -07:00
Benjamin Lu
ef453b31fd test: simplify job history browser setup 2026-03-26 21:36:09 -07:00
Benjamin Lu
61d968eb23 Merge branch 'main' into bl/com-304-job-queue-virtualization 2026-03-26 21:19:59 -07:00
Benjamin Lu
545841649e test: inline job history browser selectors 2026-03-26 20:56:39 -07:00
Benjamin Lu
0e5cbe8529 test: cover job history virtualization in browser 2026-03-26 20:42:15 -07:00
Benjamin Lu
3709d56825 fix: virtualize job queue history list 2026-03-26 20:42:15 -07:00
Benjamin Lu
2e86cb2759 test: add assets sidebar empty-state coverage 2026-03-26 19:01:58 -07:00
8 changed files with 515 additions and 91 deletions

View File

@@ -0,0 +1,127 @@
import { expect } from '@playwright/test'
import type { Locator } from '@playwright/test'
import type { RawJobListItem } from '../../../src/platform/remote/comfyui/jobs/jobTypes'
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
const VIRTUALIZED_ROW_LIMIT = 48
const HEADER_ROW_HEIGHT = 20
const JOB_ROW_HEIGHT = 48
const GROUP_ROW_GAP = 16
const GROUP_JOB_COUNTS = [6, 58]
const GROUP_BASE_TIMES = [
Date.parse('2026-03-26T20:00:00-07:00'),
Date.parse('2026-03-25T20:00:00-07:00')
]
const GROUP_BOUNDARY_JOB_ID = `job-${GROUP_JOB_COUNTS[0] - 1}`
const LAST_JOB_ID = `job-${GROUP_JOB_COUNTS[0] + GROUP_JOB_COUNTS[1] - 1}`
function buildCompletedJobs(): RawJobListItem[] {
return GROUP_BASE_TIMES.flatMap((baseTime, groupIndex) =>
Array.from({ length: GROUP_JOB_COUNTS[groupIndex] }, (_, jobIndex) => {
const endTime = baseTime - jobIndex * 5 * 60_000
const id =
GROUP_JOB_COUNTS.slice(0, groupIndex).reduce(
(total, count) => total + count,
0
) + jobIndex
return {
id: `job-${id}`,
status: 'completed',
create_time: endTime,
execution_start_time: endTime - 10_000,
execution_end_time: endTime,
workflow_id: 'workflow-1'
}
})
)
}
async function getRowMetrics(jobHistoryList: Locator, boundaryJobId: string) {
return await jobHistoryList.evaluate((element, jobId) => {
const wrapper = element.firstElementChild
const firstHeader = wrapper?.firstElementChild
const firstJob = element.querySelector('[data-job-id="job-0"]')
const boundaryJob = element.querySelector(`[data-job-id="${jobId}"]`)
if (
!(wrapper instanceof HTMLElement) ||
!(firstHeader instanceof HTMLElement) ||
!(firstJob instanceof HTMLElement) ||
!(boundaryJob instanceof HTMLElement) ||
!(firstJob.parentElement instanceof HTMLElement) ||
!(boundaryJob.parentElement instanceof HTMLElement)
) {
throw new Error('Expected virtualized job rows to be present')
}
return {
firstHeaderHeight: firstHeader.getBoundingClientRect().height,
firstHeaderScrollHeight: firstHeader.scrollHeight,
firstJobContentHeight: firstJob.getBoundingClientRect().height,
firstJobRowHeight: firstJob.parentElement.getBoundingClientRect().height,
boundaryJobContentHeight: boundaryJob.getBoundingClientRect().height,
boundaryJobRowHeight:
boundaryJob.parentElement.getBoundingClientRect().height
}
}, boundaryJobId)
}
test.describe('Job history sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.assets.mockOutputHistory(buildCompletedJobs())
await comfyPage.setup()
await comfyPage.settings.setSetting('Comfy.Queue.QPOV2', true)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.assets.clearMocks()
})
test('virtualizes large job history lists', async ({ comfyPage }) => {
const jobHistoryButton = comfyPage.page.locator('.job-history-tab-button')
const jobHistoryList = comfyPage.page.getByTestId('job-assets-list')
const jobRows = comfyPage.page.locator(
'.sidebar-content-container [data-job-id]'
)
const jobRow = (jobId: string) =>
comfyPage.page.locator(
`.sidebar-content-container [data-job-id="${jobId}"]`
)
await jobHistoryButton.click()
await jobHistoryList.waitFor({ state: 'visible' })
await expect(jobRow('job-0')).toBeVisible()
await expect(jobRow(LAST_JOB_ID)).toHaveCount(0)
const initialRenderedRows = await jobRows.count()
expect(initialRenderedRows).toBeLessThan(VIRTUALIZED_ROW_LIMIT)
const rowMetrics = await getRowMetrics(
jobHistoryList,
GROUP_BOUNDARY_JOB_ID
)
expect(rowMetrics.firstHeaderHeight).toBe(HEADER_ROW_HEIGHT)
expect(rowMetrics.firstHeaderScrollHeight).toBeLessThanOrEqual(
HEADER_ROW_HEIGHT
)
expect(rowMetrics.firstJobContentHeight).toBe(JOB_ROW_HEIGHT)
expect(rowMetrics.firstJobRowHeight).toBe(JOB_ROW_HEIGHT)
expect(rowMetrics.boundaryJobContentHeight).toBe(JOB_ROW_HEIGHT)
expect(rowMetrics.boundaryJobRowHeight).toBe(JOB_ROW_HEIGHT + GROUP_ROW_GAP)
await jobHistoryList.evaluate((element) => {
element.scrollTop = element.scrollHeight
})
await comfyPage.nextFrame()
await expect(jobRow(LAST_JOB_ID)).toBeVisible()
await expect(jobRow('job-0')).toHaveCount(0)
const renderedRowsAfterScroll = await jobRows.count()
expect(renderedRowsAfterScroll).toBeLessThan(VIRTUALIZED_ROW_LIMIT)
})
})

View File

@@ -20,7 +20,7 @@
@update:selected-sort-mode="$emit('update:selectedSortMode', $event)"
/>
<div class="min-h-0 flex-1 overflow-y-auto">
<div class="min-h-0 flex-1">
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItemEvent"

View File

@@ -8,6 +8,30 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
import JobAssetsList from './JobAssetsList.vue'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
const vue = await import('vue')
return {
...actual,
useVirtualList: (rows: unknown) => ({
list: vue.computed(() =>
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
data,
index
}))
),
scrollTo: vi.fn(),
containerProps: {
ref: vue.ref(null),
onScroll: vi.fn(),
style: {}
},
wrapperProps: vue.computed(() => ({ style: {} }))
})
}
})
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {
@@ -122,6 +146,63 @@ afterEach(() => {
})
describe('JobAssetsList', () => {
it('renders grouped headers alongside job rows', () => {
const wrapper = mount(JobAssetsList, {
props: {
displayedJobGroups: [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob({ id: 'job-2', title: 'Job 2' })]
}
]
},
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
expect(wrapper.text()).toContain('Today')
expect(wrapper.text()).toContain('Yesterday')
expect(wrapper.find('[data-job-id="job-1"]').exists()).toBe(true)
expect(wrapper.find('[data-job-id="job-2"]').exists()).toBe(true)
})
it('forwards parent attrs to the scroll container', () => {
const wrapper = mount(JobAssetsList, {
attrs: {
class: 'min-h-0 flex-1'
},
props: {
displayedJobGroups: [
{
key: 'today',
label: 'Today',
items: [buildJob({ id: 'job-1' })]
}
]
},
global: {
stubs: {
teleport: true,
JobDetailsPopover: JobDetailsPopoverStub
}
}
})
expect(wrapper.get('[data-testid="job-assets-list"]').classes()).toEqual(
expect.arrayContaining(['min-h-0', 'flex-1', 'h-full', 'overflow-y-auto'])
)
})
it('emits viewItem on preview-click for completed jobs with preview', async () => {
const job = buildJob()
const wrapper = mountJobAssetsList([job])

View File

@@ -1,79 +1,93 @@
<template>
<div class="flex flex-col gap-4 px-3 pb-4">
<div
v-for="group in displayedJobGroups"
:key="group.key"
class="flex flex-col gap-2"
>
<div class="text-xs leading-none text-text-secondary">
{{ group.label }}
</div>
<div
v-for="job in group.items"
:key="job.id"
:data-job-id="job.id"
@mouseenter="onJobEnter(job, $event)"
@mouseleave="onJobLeave(job.id)"
>
<AssetsListItem
:class="
cn(
'w-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(job)"
:is-video-preview="isVideoPreviewJob(job)"
:preview-alt="job.title"
:icon-name="job.iconName ?? iconForJobState(job.state)"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', job, $event)"
@dblclick.stop="emitViewItem(job)"
@preview-click="emitViewItem(job)"
@click.stop
<div
v-bind="$attrs"
:ref="containerProps.ref"
:style="containerProps.style"
data-testid="job-assets-list"
class="h-full overflow-y-auto"
@scroll="onListScroll"
>
<div :style="virtualWrapperStyle">
<template v-for="{ data: row } in virtualRows" :key="row.key">
<div
v-if="row.type === 'header'"
class="box-border px-3 pb-2 text-xs leading-none text-text-secondary"
:style="{ height: `${row.height}px` }"
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="isCancelable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(job)"
{{ row.label }}
</div>
<div
v-else-if="row.type === 'job'"
class="box-border px-3"
:style="{ height: `${row.height}px` }"
>
<div
:data-job-id="row.job.id"
class="h-12"
@mouseenter="onJobEnter(row.job, $event)"
@mouseleave="onJobLeave(row.job.id)"
>
<AssetsListItem
:class="
cn(
'size-full shrink-0 cursor-default text-text-primary transition-colors hover:bg-secondary-background-hover',
row.job.state === 'running' && 'bg-secondary-background'
)
"
:preview-url="getJobPreviewUrl(row.job)"
:is-video-preview="isVideoPreviewJob(row.job)"
:preview-alt="row.job.title"
:icon-name="row.job.iconName ?? iconForJobState(row.job.state)"
:icon-class="getJobIconClass(row.job)"
:primary-text="row.job.title"
:secondary-text="row.job.meta"
:progress-total-percent="row.job.progressTotalPercent"
:progress-current-percent="row.job.progressCurrentPercent"
@contextmenu.prevent.stop="$emit('menu', row.job, $event)"
@dblclick.stop="emitViewItem(row.job)"
@preview-click="emitViewItem(row.job)"
@click.stop
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
<template v-if="hoveredJobId === row.job.id" #actions>
<Button
v-if="isCancelable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.cancel')"
@click.stop="emitCancelItem(row.job)"
>
<i class="icon-[lucide--x] size-4" />
</Button>
<Button
v-else-if="isFailedDeletable(row.job)"
variant="destructive"
size="icon"
:aria-label="t('g.delete')"
@click.stop="emitDeleteItem(row.job)"
>
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
>
{{ t('menuLabels.View') }}
</Button>
<Button
variant="secondary"
size="icon"
:aria-label="t('g.more')"
@click.stop="$emit('menu', row.job, $event)"
>
<i class="icon-[lucide--ellipsis] size-4" />
</Button>
</template>
</AssetsListItem>
</div>
</div>
</template>
</div>
</div>
@@ -97,8 +111,9 @@
</template>
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { nextTick, ref } from 'vue'
import { computed, nextTick, ref } from 'vue'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import { getHoverPopoverPosition } from '@/components/queue/job/getHoverPopoverPosition'
@@ -110,6 +125,16 @@ import { cn } from '@/utils/tailwindUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { isActiveJobState } from '@/utils/queueUtil'
import {
buildVirtualJobRows,
JOB_ROW_HEIGHT,
VIRTUAL_JOB_LIST_BOTTOM_PADDING
} from './buildVirtualJobRows'
defineOptions({
inheritAttrs: false
})
const { displayedJobGroups } = defineProps<{ displayedJobGroups: JobGroup[] }>()
const emit = defineEmits<{
@@ -123,6 +148,23 @@ const { t } = useI18n()
const hoveredJobId = ref<string | null>(null)
const activeRowElement = ref<HTMLElement | null>(null)
const popoverPosition = ref<{ top: number; left: number } | null>(null)
const flatRows = computed(() => buildVirtualJobRows(displayedJobGroups))
const {
list: virtualRows,
containerProps,
wrapperProps
} = useVirtualList(flatRows, {
itemHeight: (index) => flatRows.value[index]?.height ?? JOB_ROW_HEIGHT,
overscan: 12
})
const virtualWrapperStyle = computed(() => ({
...wrapperProps.value.style,
width: '100%',
paddingBottom:
flatRows.value.length > 0
? `${VIRTUAL_JOB_LIST_BOTTOM_PADDING}px`
: undefined
}))
const {
activeDetails,
clearHoverTimers,
@@ -135,6 +177,12 @@ const {
onReset: clearPopoverAnchor
})
function onListScroll() {
containerProps.onScroll()
hoveredJobId.value = null
resetActiveDetails()
}
function clearPopoverAnchor() {
activeRowElement.value = null
popoverPosition.value = null

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest'
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
import { buildVirtualJobRows } from './buildVirtualJobRows'
function buildJob(id: string): JobListItem {
return {
id,
title: `Job ${id}`,
meta: 'meta',
state: 'completed'
}
}
describe('buildVirtualJobRows', () => {
it('flattens grouped jobs into headers and rows in display order', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1'), buildJob('job-2')]
},
{
key: 'yesterday',
label: 'Yesterday',
items: [buildJob('job-3')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today',
height: 20
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0],
height: 48
},
{
key: 'job-job-2',
type: 'job',
job: displayedJobGroups[0].items[1],
height: 64
},
{
key: 'header-yesterday',
type: 'header',
label: 'Yesterday',
height: 20
},
{
key: 'job-job-3',
type: 'job',
job: displayedJobGroups[1].items[0],
height: 48
}
])
})
it('keeps the last group rows at the base job height', () => {
const displayedJobGroups: JobGroup[] = [
{
key: 'today',
label: 'Today',
items: [buildJob('job-1')]
}
]
expect(buildVirtualJobRows(displayedJobGroups)).toEqual([
{
key: 'header-today',
type: 'header',
label: 'Today',
height: 20
},
{
key: 'job-job-1',
type: 'job',
job: displayedJobGroups[0].items[0],
height: 48
}
])
})
})

View File

@@ -0,0 +1,52 @@
import type { JobGroup, JobListItem } from '@/composables/queue/useJobList'
const HEADER_ROW_HEIGHT = 20
const GROUP_ROW_GAP = 16
export const JOB_ROW_HEIGHT = 48
export const VIRTUAL_JOB_LIST_BOTTOM_PADDING = 16
export type VirtualJobRow =
| {
key: string
type: 'header'
label: string
height: number
}
| {
key: string
type: 'job'
job: JobListItem
height: number
}
export function buildVirtualJobRows(
displayedJobGroups: JobGroup[]
): VirtualJobRow[] {
const rows: VirtualJobRow[] = []
const lastGroupIndex = displayedJobGroups.length - 1
displayedJobGroups.forEach((group, groupIndex) => {
rows.push({
key: `header-${group.key}`,
type: 'header',
label: group.label,
height: HEADER_ROW_HEIGHT
})
group.items.forEach((job, jobIndex) => {
const isLastJobInGroup = jobIndex === group.items.length - 1
const isLastGroup = groupIndex === lastGroupIndex
rows.push({
key: `job-${job.id}`,
type: 'job',
job,
height:
JOB_ROW_HEIGHT +
(isLastJobInGroup && !isLastGroup ? GROUP_ROW_GAP : 0)
})
})
})
return rows
}

View File

@@ -5,6 +5,30 @@ import { defineComponent, nextTick } from 'vue'
import JobHistorySidebarTab from './JobHistorySidebarTab.vue'
vi.mock('@vueuse/core', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@vueuse/core')
const vue = await import('vue')
return {
...actual,
useVirtualList: (rows: unknown) => ({
list: vue.computed(() =>
vue.unref(rows as readonly unknown[]).map((data: unknown, index) => ({
data,
index
}))
),
scrollTo: vi.fn(),
containerProps: {
ref: vue.ref(null),
onScroll: vi.fn(),
style: {}
},
wrapperProps: vue.computed(() => ({ style: {} }))
})
}
})
const JobDetailsPopoverStub = defineComponent({
name: 'JobDetailsPopover',
props: {

View File

@@ -46,22 +46,25 @@
</div>
</template>
<template #body>
<JobAssetsList
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
<div class="flex h-full min-h-0 flex-col">
<JobAssetsList
class="min-h-0 flex-1"
:displayed-job-groups="displayedJobGroups"
@cancel-item="onCancelItem"
@delete-item="onDeleteItem"
@view-item="onViewItem"
@menu="onMenuItem"
/>
<JobContextMenu
ref="jobContextMenuRef"
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
</div>
</template>
</SidebarTabTemplate>
</template>