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:
Dante
2026-03-13 23:49:42 +09:00
committed by GitHub
parent 4e85537b15
commit d73f8e1beb
4 changed files with 223 additions and 21 deletions

View File

@@ -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',

View File

@@ -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,

View File

@@ -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)', () => {

View File

@@ -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)
)
}