mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-14 01:20:03 +00:00
fix: show most recent image first in asset sidebar batch view (#9467)
## Summary - Use the last previewable output as the batch cover/thumbnail instead of the first, so the most recently generated image (e.g., `_00010`) is shown as the representative - Reverse output order in batch folder view so newest images appear at the top - Gitignore `.claude/worktrees` to fix knip scanning untracked worktree copies ## Linked Issues - Fixes #9354 - Related to #9080 ## Test plan - [ ] Generate a batch of images (e.g., 10 images) and verify the sidebar shows the last generated image as the cover - [ ] Expand the batch folder view and verify images are in reverse-chronological order (newest first) - [ ] Verify existing unit tests pass (`pnpm test:unit`) 🤖 Generated with [Claude Code](https://claude.com/claude-code) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9467-fix-show-most-recent-image-first-in-asset-sidebar-batch-view-31b6d73d365081cbaf30f81009c7fcfa) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -89,7 +89,7 @@ describe('resolveOutputAssetItems', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('loads full outputs when metadata indicates more outputs', async () => {
|
||||
it('loads full outputs when metadata indicates more outputs (newest first)', async () => {
|
||||
const previewOutput = createOutput({
|
||||
filename: 'preview.png',
|
||||
nodeId: '1',
|
||||
@@ -121,12 +121,66 @@ describe('resolveOutputAssetItems', () => {
|
||||
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
|
||||
jobDetail
|
||||
)
|
||||
// Outputs are reversed so the most recent appears first
|
||||
expect(results.map((asset) => asset.name)).toEqual([
|
||||
'full.png',
|
||||
'preview.png'
|
||||
'preview.png',
|
||||
'full.png'
|
||||
])
|
||||
})
|
||||
|
||||
it('reverses outputs and excludes the correct key simultaneously', async () => {
|
||||
const outputA = createOutput({
|
||||
filename: 'a.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/a.png'
|
||||
})
|
||||
const outputB = createOutput({
|
||||
filename: 'b.png',
|
||||
nodeId: '2',
|
||||
url: 'https://example.com/b.png'
|
||||
})
|
||||
const outputC = createOutput({
|
||||
filename: 'c.png',
|
||||
nodeId: '3',
|
||||
url: 'https://example.com/c.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-combo',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 3,
|
||||
allOutputs: [outputA, outputB, outputC]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
excludeOutputKey: '2-sub-b.png'
|
||||
})
|
||||
|
||||
// outputB excluded, remaining reversed: [C, A]
|
||||
expect(results.map((asset) => asset.name)).toEqual(['c.png', 'a.png'])
|
||||
})
|
||||
|
||||
it('returns empty array when all outputs are excluded', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'only.png',
|
||||
nodeId: '1',
|
||||
url: 'https://example.com/only.png'
|
||||
})
|
||||
const metadata: OutputAssetMetadata = {
|
||||
jobId: 'job-empty',
|
||||
nodeId: '1',
|
||||
subfolder: 'sub',
|
||||
outputCount: 1,
|
||||
allOutputs: [output]
|
||||
}
|
||||
|
||||
const results = await resolveOutputAssetItems(metadata, {
|
||||
excludeOutputKey: '1-sub-only.png'
|
||||
})
|
||||
|
||||
expect(results).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('propagates display_name from output to asset item', async () => {
|
||||
const output = createOutput({
|
||||
filename: 'abc123hash.png',
|
||||
|
||||
@@ -101,9 +101,10 @@ export async function resolveOutputAssetItems(
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse so the most recent outputs appear first
|
||||
return mapOutputsToAssetItems({
|
||||
jobId: metadata.jobId,
|
||||
outputs: outputsToDisplay,
|
||||
outputs: outputsToDisplay.toReversed(),
|
||||
createdAt,
|
||||
executionTimeInSeconds: metadata.executionTimeInSeconds,
|
||||
workflow: metadata.workflow,
|
||||
|
||||
@@ -71,6 +71,19 @@ vi.mock('@/stores/modelToNodeStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
type MockOutput = {
|
||||
supportsPreview: boolean
|
||||
filename: string
|
||||
subfolder: string
|
||||
type: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// Per-test override for mock outputs (defaults to single output)
|
||||
const mockOutputOverrides = vi.hoisted(() => ({
|
||||
value: null as MockOutput[] | null
|
||||
}))
|
||||
|
||||
// Mock TaskItemImpl
|
||||
const PREVIEWABLE_MEDIA_TYPES = new Set(['images', 'video', 'audio'])
|
||||
|
||||
@@ -98,22 +111,31 @@ vi.mock('@/stores/queueStore', () => ({
|
||||
constructor(public job: JobListItem) {
|
||||
this.jobId = job.id
|
||||
this.outputsCount = job.outputs_count ?? null
|
||||
const preview = job.preview_output
|
||||
const isPreviewable =
|
||||
!!preview?.filename && PREVIEWABLE_MEDIA_TYPES.has(preview.mediaType)
|
||||
if (preview && isPreviewable) {
|
||||
const item = {
|
||||
supportsPreview: true,
|
||||
filename: preview.filename!,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
url: `http://test.com/${preview.filename}`
|
||||
}
|
||||
this.flatOutputs = [item]
|
||||
this.previewOutput = item
|
||||
if (mockOutputOverrides.value) {
|
||||
this.flatOutputs = mockOutputOverrides.value
|
||||
const previewable = mockOutputOverrides.value.filter(
|
||||
(o) => o.supportsPreview
|
||||
)
|
||||
this.previewOutput =
|
||||
previewable.findLast((o) => o.type === 'output') ?? previewable.at(-1)
|
||||
} else {
|
||||
this.flatOutputs = []
|
||||
this.previewOutput = undefined
|
||||
const preview = job.preview_output
|
||||
const isPreviewable =
|
||||
!!preview?.filename && PREVIEWABLE_MEDIA_TYPES.has(preview.mediaType)
|
||||
if (preview && isPreviewable) {
|
||||
const item = {
|
||||
supportsPreview: true,
|
||||
filename: preview.filename!,
|
||||
subfolder: preview.subfolder ?? '',
|
||||
type: preview.type ?? 'output',
|
||||
url: `http://test.com/${preview.filename}`
|
||||
}
|
||||
this.flatOutputs = [item]
|
||||
this.previewOutput = item
|
||||
} else {
|
||||
this.flatOutputs = []
|
||||
this.previewOutput = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,6 +554,130 @@ describe('assetsStore - Refactored (Option A)', () => {
|
||||
expect(Array.isArray(asset.user_metadata!.allOutputs)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cover Image Selection', () => {
|
||||
afterEach(() => {
|
||||
mockOutputOverrides.value = null
|
||||
})
|
||||
|
||||
it('should use the last saved output as cover for multi-output batches', async () => {
|
||||
mockOutputOverrides.value = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'batch_00001.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/batch_00001.png'
|
||||
},
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'batch_00005.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/batch_00005.png'
|
||||
},
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'batch_00010.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/batch_00010.png'
|
||||
}
|
||||
]
|
||||
|
||||
const mockHistory = [createMockJobItem(0)]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
// Cover should be the last output (most recent in batch)
|
||||
expect(store.historyAssets[0].name).toBe('batch_00010.png')
|
||||
})
|
||||
|
||||
it('should prefer last saved output over temp previews', async () => {
|
||||
mockOutputOverrides.value = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'saved_first.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/saved_first.png'
|
||||
},
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'saved_last.png',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/saved_last.png'
|
||||
},
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'temp_preview.png',
|
||||
subfolder: '',
|
||||
type: 'temp',
|
||||
url: 'http://test.com/temp_preview.png'
|
||||
}
|
||||
]
|
||||
|
||||
const mockHistory = [createMockJobItem(0)]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
// Should pick last saved output, not the temp preview
|
||||
expect(store.historyAssets[0].name).toBe('saved_last.png')
|
||||
})
|
||||
|
||||
it('should fall back to last temp output when no saved outputs exist', async () => {
|
||||
mockOutputOverrides.value = [
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'temp_first.png',
|
||||
subfolder: '',
|
||||
type: 'temp',
|
||||
url: 'http://test.com/temp_first.png'
|
||||
},
|
||||
{
|
||||
supportsPreview: true,
|
||||
filename: 'temp_last.png',
|
||||
subfolder: '',
|
||||
type: 'temp',
|
||||
url: 'http://test.com/temp_last.png'
|
||||
}
|
||||
]
|
||||
|
||||
const mockHistory = [createMockJobItem(0)]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
expect(store.historyAssets).toHaveLength(1)
|
||||
// No saved outputs, should fall back to last previewable
|
||||
expect(store.historyAssets[0].name).toBe('temp_last.png')
|
||||
})
|
||||
|
||||
it('should skip jobs with no previewable outputs', async () => {
|
||||
mockOutputOverrides.value = [
|
||||
{
|
||||
supportsPreview: false,
|
||||
filename: 'not_previewable.dat',
|
||||
subfolder: '',
|
||||
type: 'output',
|
||||
url: 'http://test.com/not_previewable.dat'
|
||||
}
|
||||
]
|
||||
|
||||
const mockHistory = [createMockJobItem(0)]
|
||||
vi.mocked(api.getHistory).mockResolvedValue(mockHistory)
|
||||
|
||||
await store.updateHistory()
|
||||
|
||||
// No previewable outputs → job should be skipped
|
||||
expect(store.historyAssets).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('assetsStore - Model Assets Cache (Cloud)', () => {
|
||||
|
||||
@@ -288,9 +288,10 @@ export class TaskItemImpl {
|
||||
|
||||
get previewOutput(): ResultItemImpl | undefined {
|
||||
const previewable = this.previewableOutputs
|
||||
// Prefer saved media files over the temp previews
|
||||
// Prefer the last saved media file (most recent result) over temp previews
|
||||
return (
|
||||
previewable.find((output) => output.type === 'output') ?? previewable[0]
|
||||
previewable.findLast((output) => output.type === 'output') ??
|
||||
previewable.at(-1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user