Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
94884d7
feat: add queue view toggle stub
benceruleanlu Dec 20, 2025
5ddea4e
Extract to component
benceruleanlu Dec 20, 2025
a0dad31
Add feature flag
benceruleanlu Dec 22, 2025
3d0c0d1
Move feature flag to setting
benceruleanlu Dec 31, 2025
dc53cbe
Add N active jobs and clear queue button
benceruleanlu Dec 22, 2025
2fd9a73
Readd divider as v-else
benceruleanlu Dec 23, 2025
0c6ea56
fix: read QPOV2 setting in assets sidebar
benceruleanlu Jan 1, 2026
5b14568
Add AssetsListCard base template
benceruleanlu Dec 22, 2025
da48899
Add AssetsListCard stories
benceruleanlu Dec 22, 2025
f614914
Remove view action from AssetsListCard story
benceruleanlu Dec 22, 2025
85c6825
Add list view
benceruleanlu Dec 23, 2025
627db67
knip
benceruleanlu Dec 23, 2025
afa4664
[automated] Apply ESLint and Prettier fixes
actions-user Dec 23, 2025
f128c61
Remove special failed job styling
benceruleanlu Dec 23, 2025
55db4fe
Merge remote-tracking branch 'origin/main' into bl-precious-earthworm
benceruleanlu Jan 6, 2026
5ed4e5f
Rename card to item
benceruleanlu Jan 7, 2026
9e477d4
Add select-none to list item
benceruleanlu Jan 7, 2026
e9d675e
refactor: share progress bar background helpers
benceruleanlu Jan 7, 2026
ede934a
Inline job card class
benceruleanlu Jan 7, 2026
114ece5
Inline list card base class
benceruleanlu Jan 7, 2026
f9eb1c4
Extract media icon mapping
benceruleanlu Jan 7, 2026
7c82218
refactor: simplify job cancel action
benceruleanlu Jan 7, 2026
9c4f808
knip
benceruleanlu Jan 7, 2026
72a9571
refactor: simplify job action sets
benceruleanlu Jan 7, 2026
ab09e7a
refactor: make job menu actions explicit
benceruleanlu Jan 7, 2026
a8fa3e3
feat: add icon slot to assets list item
benceruleanlu Jan 7, 2026
434ee52
fix: prevent active job items from shrinking
benceruleanlu Jan 7, 2026
578b8fb
Refactor job actions to single cancel and formalUtil extraction
benceruleanlu Jan 9, 2026
0f02b9f
refactor: make job cancel action reactive
benceruleanlu Jan 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 20 additions & 9 deletions src/components/queue/job/QueueJobItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,22 @@
<div
v-if="
props.state === 'running' &&
(props.progressTotalPercent !== undefined ||
props.progressCurrentPercent !== undefined)
hasAnyProgressPercent(
props.progressTotalPercent,
props.progressCurrentPercent
)
"
class="absolute inset-0"
:class="progressBarContainerClass"
>
<div
v-if="props.progressTotalPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${props.progressTotalPercent}%` }"
v-if="hasProgressPercent(props.progressTotalPercent)"
:class="progressBarPrimaryClass"
:style="progressPercentStyle(props.progressTotalPercent)"
/>
<div
v-if="props.progressCurrentPercent !== undefined"
class="pointer-events-none absolute inset-y-0 left-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${props.progressCurrentPercent}%` }"
v-if="hasProgressPercent(props.progressCurrentPercent)"
:class="progressBarSecondaryClass"
:style="progressPercentStyle(props.progressCurrentPercent)"
/>
</div>

Expand Down Expand Up @@ -201,6 +203,7 @@ import { useI18n } from 'vue-i18n'
import JobDetailsPopover from '@/components/queue/job/JobDetailsPopover.vue'
import QueueAssetPreview from '@/components/queue/job/QueueAssetPreview.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { buildTooltipConfig } from '@/composables/useTooltipConfig'
import type { JobState } from '@/types/queue'
import { iconForJobState } from '@/utils/queueDisplay'
Expand Down Expand Up @@ -245,6 +248,14 @@ const emit = defineEmits<{
}>()

const { t } = useI18n()
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
hasProgressPercent,
hasAnyProgressPercent,
progressPercentStyle
} = useProgressBarBackground()

const cancelTooltipConfig = computed(() => buildTooltipConfig(t('g.cancel')))
const deleteTooltipConfig = computed(() => buildTooltipConfig(t('g.delete')))
Expand Down
203 changes: 203 additions & 0 deletions src/components/sidebar/tabs/AssetsSidebarListView.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<template>
<div class="flex h-full flex-col">
<div
v-if="activeJobItems.length"
class="flex max-h-[50%] flex-col gap-2 overflow-y-auto px-2"
>
<AssetsListItem
v-for="job in activeJobItems"
:key="job.id"
:class="
cn(
'w-full shrink-0 text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-default'
)
"
:preview-url="job.iconImageUrl"
:preview-alt="job.title"
:icon-name="job.iconName"
:icon-class="getJobIconClass(job)"
:primary-text="job.title"
:secondary-text="job.meta"
:progress-total-percent="job.progressTotalPercent"
:progress-current-percent="job.progressCurrentPercent"
@mouseenter="onJobEnter(job.id)"
@mouseleave="onJobLeave(job.id)"
@click.stop
>
<template v-if="hoveredJobId === job.id" #actions>
<Button
v-if="canCancelJob"
:variant="cancelAction.variant"
size="icon"
:aria-label="cancelAction.label"
@click.stop="runCancelJob()"
>
<i :class="cancelAction.icon" class="size-4" />
</Button>
</template>
</AssetsListItem>
</div>

<div
v-if="assets.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
class="flex items-center py-2 text-sm font-normal leading-normal text-muted-foreground font-inter"
>
{{ t('sideToolbar.generatedAssetsHeader') }}
</div>
</div>

<VirtualGrid
class="flex-1"
:items="assetItems"
:grid-style="listGridStyle"
@approach-end="emit('approach-end')"
>
<template #item="{ item }">
<AssetsListItem
role="button"
tabindex="0"
:aria-label="
t('assetBrowser.ariaLabel.assetCard', {
name: item.asset.name,
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
iconForMediaType(getMediaTypeFromFilename(item.asset.name))
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
@click.stop="emit('select-asset', item.asset)"
/>
</template>
</VirtualGrid>
</div>
</template>

<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'

import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import type { JobState } from '@/types/queue'
import {
formatDuration,
formatSize,
getMediaTypeFromFilename,
truncateFilename
} from '@/utils/formatUtil'
import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'

const { assets, isSelected } = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
}>()

const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'approach-end'): void
}>()

const { t } = useI18n()
const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null)

type AssetListItem = { key: string; asset: AssetItem }

const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
const hoveredJob = computed(() =>
hoveredJobId.value
? (activeJobItems.value.find((job) => job.id === hoveredJobId.value) ??
null)
: null
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)

const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)

const listGridStyle = {
display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)',
padding: '0 0.5rem',
gap: '0.5rem'
}

function isActiveJobState(state: JobState): boolean {
return (
state === 'pending' || state === 'initialization' || state === 'running'
)
}
Comment on lines +148 to +152
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Eliminate code duplication with useJobActions.

The isActiveJobState function duplicates the cancellableStates logic from useJobActions.ts (line 29). Both define the same three states: pending, initialization, and running. If the definition of active/cancellable states changes, both locations would need updates.

♻️ Proposed refactor

Option 1: Export a shared constant from a common location

Create or use an existing constants file (e.g., src/types/queue.ts or src/constants/queue.ts):

// src/types/queue.ts or src/constants/queue.ts
export const ACTIVE_JOB_STATES: JobState[] = [
  'pending',
  'initialization',
  'running'
] as const

Then import and use in both files:

In useJobActions.ts:

+import { ACTIVE_JOB_STATES } from '@/types/queue'
+
 export function useJobActions(
   job: MaybeRefOrGetter<JobListItem | null | undefined>
 ) {
   // ...
   return (
     currentJob.showClear !== false &&
-    cancellableStates.includes(currentJob.state)
+    ACTIVE_JOB_STATES.includes(currentJob.state)
   )
 }

In AssetsSidebarListView.vue:

+import { ACTIVE_JOB_STATES } from '@/types/queue'
+
 function isActiveJobState(state: JobState): boolean {
-  return (
-    state === 'pending' || state === 'initialization' || state === 'running'
-  )
+  return ACTIVE_JOB_STATES.includes(state)
 }

Option 2: Import directly from useJobActions (if you export the constant)

This centralizes the definition in the composable that owns the logic.

This refactor improves maintainability by ensuring the definition of active/cancellable job states is managed in one place.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/components/sidebar/tabs/AssetsSidebarListView.vue around lines 148 -
152, isActiveJobState duplicates the cancellableStates list from
useJobActions.ts; centralize the definition to avoid divergence by exporting a
shared constant (e.g., ACTIVE_JOB_STATES) and using it in both places. Update
useJobActions.ts to export the constant (or move it to a shared file like
src/types/queue.ts or src/constants/queue.ts), then replace the local
cancellableStates/reference in useJobActions and replace isActiveJobState in
AssetsSidebarListView.vue to check membership against the exported
ACTIVE_JOB_STATES (or import from useJobActions if you choose that location).
Ensure types remain JobState[] and adjust imports accordingly.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai make a tracking issue for this in the future

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Create a GitHub issue to track the code duplication refactoring

gh issue create \
  --repo "Comfy-Org/ComfyUI_frontend" \
  --title "Refactor: Eliminate code duplication between isActiveJobState and cancellableStates" \
  --body "## Context

Code duplication exists between:
- \`isActiveJobState\` function in \`src/components/sidebar/tabs/AssetsSidebarListView.vue\` (lines 148-152)
- \`cancellableStates\` in \`src/composables/queue/useJobActions.ts\` (line 29)

Both define the same three job states: \`pending\`, \`initialization\`, and \`running\`.

## Problem

If the definition of active/cancellable states changes, both locations would need updates, creating a maintenance burden and risk of divergence.

## Proposed Solution

Centralize the definition by:
1. Creating a shared constant (e.g., \`ACTIVE_JOB_STATES\`) in a common location like \`src/types/queue.ts\` or \`src/constants/queue.ts\`
2. Updating both files to import and use the shared constant
3. Replacing the local \`cancellableStates\` set and \`isActiveJobState\` function with references to the shared constant

## References

- Related PR: #7737
- Review comment: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7737#discussion_r2674607316
- Requested by: @christian-byrne" \
  --assignee "christian-byrne"

Length of output: 57


@christian-byrne I've created issue #7947 to track this refactoring. The issue documents the code duplication between isActiveJobState and cancellableStates, explains the maintenance risk, and proposes centralizing the definition in a shared constant. It's been assigned to you and includes references back to this PR and review comment.


🧠 Learnings used
Learnt from: CR
Repo: Comfy-Org/ComfyUI_frontend PR: 0
File: src/components/CLAUDE.md:0-0
Timestamp: 2025-11-24T19:47:45.616Z
Learning: Applies to src/components/**/*.vue : Replace PrimeVue Sidebar component with Drawer


function getAssetPrimaryText(asset: AssetItem): string {
return truncateFilename(asset.name)
}

function getAssetSecondaryText(asset: AssetItem): string {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.executionTimeInSeconds === 'number') {
return `${metadata.executionTimeInSeconds.toFixed(2)}s`
}

const duration = asset.user_metadata?.duration
if (typeof duration === 'number') {
return formatDuration(duration)
}

if (typeof asset.size === 'number') {
return formatSize(asset.size)
}

return ''
}

function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',
'cursor-pointer',
selected &&
'bg-secondary-background-hover ring-1 ring-inset ring-modal-card-border-highlighted'
)
}

function onJobEnter(jobId: string) {
hoveredJobId.value = jobId
}

function onJobLeave(jobId: string) {
if (hoveredJobId.value === jobId) {
hoveredJobId.value = null
}
}

function getJobIconClass(job: JobListItem): string | undefined {
const classes = []
const iconName = job.iconName ?? iconForJobState(job.state)
if (!job.iconImageUrl && iconName === iconForJobState('pending')) {
classes.push('animate-spin')
}
return classes.length ? classes.join(' ') : undefined
}
</script>
30 changes: 28 additions & 2 deletions src/components/sidebar/tabs/AssetsSidebarTab.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,10 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="loading && !displayAssets.length">
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
</div>
<div v-else-if="!loading && !displayAssets.length">
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
Expand All @@ -96,7 +96,15 @@
/>
</div>
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:is-selected="isSelected"
@select-asset="handleAssetSelect"
@approach-end="handleApproachEnd"
/>
<VirtualGrid
v-else
:items="mediaAssetsWithKey"
:grid-style="{
display: 'grid',
Expand Down Expand Up @@ -198,6 +206,7 @@ import { useI18n } from 'vue-i18n'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Load3dViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
Expand Down Expand Up @@ -232,6 +241,9 @@ const viewMode = ref<'list' | 'grid'>('grid')
const isQueuePanelV2Enabled = computed(() =>
settingStore.get('Comfy.Queue.QPOV2')
)
const isListView = computed(
() => isQueuePanelV2Enabled.value && viewMode.value === 'list'
)

// Track which asset's context menu is open (for single-instance context menu management)
const openContextMenuId = ref<string | null>(null)
Expand Down Expand Up @@ -347,6 +359,20 @@ const displayAssets = computed(() => {
return filteredAssets.value
})

const showLoadingState = computed(
() =>
loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)

const showEmptyState = computed(
() =>
!loading.value &&
displayAssets.value.length === 0 &&
(!isListView.value || activeJobsCount.value === 0)
)

Comment on lines +362 to +375
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was made because now the view can show even when there are no generated assets, because running jobs were added to this view

watch(displayAssets, (newAssets) => {
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
Expand Down
59 changes: 59 additions & 0 deletions src/composables/queue/useJobActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { computed, toValue } from 'vue'
import type { MaybeRefOrGetter } from 'vue'
import { useI18n } from 'vue-i18n'

import { useErrorHandling } from '@/composables/useErrorHandling'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobMenu } from '@/composables/queue/useJobMenu'
import type { JobState } from '@/types/queue'

type JobAction = {
icon: string
label: string
variant: 'destructive' | 'secondary' | 'textonly'
}

export function useJobActions(
job: MaybeRefOrGetter<JobListItem | null | undefined>
) {
const { t } = useI18n()
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { cancelJob } = useJobMenu()

const cancelAction: JobAction = {
icon: 'icon-[lucide--x]',
label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
variant: 'destructive'
}

const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
Comment on lines +23 to +29
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Move static data outside the function for better performance.

Both cancelAction (lines 23-27) and cancellableStates (line 29) are static data that get recreated on every composable invocation. Moving them outside as constants would improve performance and align with immutability preferences.

♻️ Proposed refactor
+const CANCELLABLE_STATES: JobState[] = [
+  'pending',
+  'initialization',
+  'running'
+]
+
 export function useJobActions(
   job: MaybeRefOrGetter<JobListItem | null | undefined>
 ) {
   const { t } = useI18n()
   const { wrapWithErrorHandlingAsync } = useErrorHandling()
   const { cancelJob } = useJobMenu()
 
-  const cancelAction: JobAction = {
+  const cancelAction: JobAction = {
     icon: 'icon-[lucide--x]',
     label: t('sideToolbar.queueProgressOverlay.cancelJobTooltip'),
     variant: 'destructive'
   }
 
-  const cancellableStates: JobState[] = ['pending', 'initialization', 'running']
-
   const jobRef = computed(() => toValue(job) ?? null)
 
   const canCancelJob = computed(() => {
     const currentJob = jobRef.value
     if (!currentJob) {
       return false
     }
 
     return (
       currentJob.showClear !== false &&
-      cancellableStates.includes(currentJob.state)
+      CANCELLABLE_STATES.includes(currentJob.state)
     )
   })

Note: cancelAction could also be moved outside, but since it uses t() from useI18n(), it would need to be initialized differently (e.g., as a factory or kept as-is). The current implementation is acceptable if i18n reactivity is desired.

Based on coding guidelines emphasizing immutability and minimal mutable state.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @src/composables/queue/useJobActions.ts around lines 23 - 29, Move the purely
static array cancellableStates out of the composable and define it as a
module-level constant named cancellableStates: JobState[] =
['pending','initialization','running'] to avoid recreating it on each
invocation; for cancelAction (symbol cancelAction) since it depends on t() from
useI18n(), either keep it inside the composable or convert it to a small factory
function (e.g., createCancelAction(t): JobAction) that builds and returns the
object so the static shape is defined outside while still allowing localization
to be injected.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai make a tracking issue for this in the future

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Create a GitHub issue for the refactoring task

gh issue create \
  --title "Refactor: Move static data outside useJobActions composable" \
  --body "## Context
This refactoring was suggested during review of PR #7737.

## File
\`src/composables/queue/useJobActions.ts\`

## Suggestion
Move static data outside the composable function for better performance:

1. **\`cancellableStates\`** (lines 29): This static array is recreated on every composable invocation. Should be moved to module-level as a constant:
   \`\`\`typescript
   const CANCELLABLE_STATES: JobState[] = ['pending', 'initialization', 'running']
   \`\`\`

2. **\`cancelAction\`** (lines 23-27): Consider moving outside or converting to a factory function, though it depends on \`t()\` from \`useI18n()\` so may need special handling for i18n reactivity.

## Benefits
- Improved performance by avoiding recreation on each invocation
- Better aligns with immutability preferences
- Reduces memory allocation

## References
- PR: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7737
- Review comment: https://github.com/Comfy-Org/ComfyUI_frontend/pull/7737#discussion_r2674607319

Based on coding guidelines emphasizing immutability and minimal mutable state." \
  --assignee christian-byrne

Length of output: 57


@christian-byrne, I've created tracking issue #7946 for the refactoring suggestion about moving static data outside the useJobActions composable.

The issue has been assigned to you and includes the context, specific refactoring details, benefits, and backlinks to this PR and review comment.


const jobRef = computed(() => toValue(job) ?? null)

const canCancelJob = computed(() => {
const currentJob = jobRef.value
if (!currentJob) {
return false
}

return (
currentJob.showClear !== false &&
cancellableStates.includes(currentJob.state)
)
})

const runCancelJob = wrapWithErrorHandlingAsync(async () => {
const currentJob = jobRef.value
if (!currentJob) {
return
}

await cancelJob(currentJob)
})

return {
cancelAction,
canCancelJob,
runCancelJob
}
}
Loading