From d73f8e1beb8a35be395b61b5efd307ac368105f7 Mon Sep 17 00:00:00 2001 From: Dante Date: Fri, 13 Mar 2026 23:49:42 +0900 Subject: [PATCH] fix: show most recent image first in asset sidebar batch view (#9467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- .../assets/utils/outputAssetUtil.test.ts | 60 +++++- src/platform/assets/utils/outputAssetUtil.ts | 3 +- src/stores/assetsStore.test.ts | 176 ++++++++++++++++-- src/stores/queueStore.ts | 5 +- 4 files changed, 223 insertions(+), 21 deletions(-) diff --git a/src/platform/assets/utils/outputAssetUtil.test.ts b/src/platform/assets/utils/outputAssetUtil.test.ts index 30adb9b613..155402beb9 100644 --- a/src/platform/assets/utils/outputAssetUtil.test.ts +++ b/src/platform/assets/utils/outputAssetUtil.test.ts @@ -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', diff --git a/src/platform/assets/utils/outputAssetUtil.ts b/src/platform/assets/utils/outputAssetUtil.ts index beab833bfc..941b96fd7f 100644 --- a/src/platform/assets/utils/outputAssetUtil.ts +++ b/src/platform/assets/utils/outputAssetUtil.ts @@ -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, diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index dfce660207..0a8cb0a75f 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -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)', () => { diff --git a/src/stores/queueStore.ts b/src/stores/queueStore.ts index d81bffdc90..252b89320b 100644 --- a/src/stores/queueStore.ts +++ b/src/stores/queueStore.ts @@ -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) ) }