Add expandable output stacks to assets list view (#8283)

Add expandable output stacks to the assets list view.

Monolith ver. of https://github.com/Comfy-Org/ComfyUI_frontend/pull/8298
and its children

List view currently collapses multi-output jobs into a single row, which
makes sibling outputs easy to miss and causes selection/zoom behavior to
drift once items are expanded elsewhere. This change adds a stack toggle
to list rows, expands child outputs derived from job data, and keeps
list-view selection and gallery navigation aligned with the expanded
list. Output mapping and “load full outputs” checks are centralized so
folder view and stacks share the same helper, and job-detail parsing now
yields previewable outputs for the list view. Asset actions now prefer
metadata prompt IDs to support the composite IDs used by stacked
outputs.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8283-Add-expandable-output-stacks-to-assets-list-view-2f16d73d365081a99fc6f1519ac2e57c)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
This commit is contained in:
Benjamin Lu
2026-02-02 03:31:01 -08:00
committed by GitHub
parent 22daf48748
commit 2740c7cdd5
17 changed files with 1054 additions and 113 deletions

View File

@@ -264,6 +264,7 @@ const focusAssetInSidebar = async (item: JobListItem) => {
throw new Error('Asset not found in media assets panel')
}
assetSelectionStore.setSelection([assetId])
assetSelectionStore.setLastSelectedAssetId(assetId)
}
const inspectJobAsset = wrapWithErrorHandlingAsync(

View File

@@ -2,7 +2,7 @@
<div class="flex h-full flex-col">
<!-- Active Jobs Grid -->
<div
v-if="isQueuePanelV2Enabled && activeJobItems.length"
v-if="!isInFolderView && isQueuePanelV2Enabled && activeJobItems.length"
class="grid max-h-[50%] scrollbar-custom overflow-y-auto"
:style="gridStyle"
>
@@ -70,12 +70,14 @@ import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
isSelected,
isInFolderView = false,
assetType = 'output',
showOutputCount,
getOutputCount
} = defineProps<{
assets: AssetItem[]
isSelected: (assetId: string) => boolean
isInFolderView?: boolean
assetType?: 'input' | 'output'
showOutputCount: (asset: AssetItem) => boolean
getOutputCount: (asset: AssetItem) => number

View File

@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { toRef } from 'vue'
import type { JobAction } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { setMockJobActions } from '@/storybook/mocks/useJobActions'
import { setMockJobItems } from '@/storybook/mocks/useJobList'
@@ -138,16 +140,33 @@ function renderAssetsSidebarListView(args: StoryArgs) {
setup() {
setMockJobItems(args.jobs)
setMockJobActions(args.actionsByJobId ?? {})
const { assetItems, selectableAssets, isStackExpanded, toggleStack } =
useOutputStacks({
assets: toRef(args, 'assets')
})
const selectedIds = new Set(args.selectedAssetIds ?? [])
function isSelected(assetId: string) {
return selectedIds.has(assetId)
}
return { args, isSelected }
return {
args,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
}
},
template: `
<div class="h-[520px] w-[320px] overflow-hidden rounded-lg border border-panel-border">
<AssetsSidebarListView :assets="args.assets" :is-selected="isSelected" />
<AssetsSidebarListView
:asset-items="assetItems"
:selectable-assets="selectableAssets"
:is-selected="isSelected"
:is-stack-expanded="isStackExpanded"
:toggle-stack="toggleStack"
/>
</div>
`
}

View File

@@ -40,7 +40,7 @@
</div>
<div
v-if="assets.length"
v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')"
>
<div
@@ -79,7 +79,12 @@
type: getMediaTypeFromFilename(item.asset.name)
})
"
:class="getAssetCardClass(isSelected(item.asset.id))"
:class="
cn(
getAssetCardClass(isSelected(item.asset.id)),
item.isChild && 'pl-6'
)
"
:preview-url="item.asset.preview_url"
:preview-alt="item.asset.name"
:icon-name="
@@ -87,10 +92,14 @@
"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)"
@click.stop="emit('select-asset', item.asset)"
@click.stop="emit('select-asset', item.asset, selectableAssets)"
@stack-toggle="void toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
@@ -120,6 +129,7 @@ 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 type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
@@ -136,19 +146,25 @@ import { cn } from '@/utils/tailwindUtil'
import { useSettingStore } from '@/platform/settings/settingStore'
const {
assets,
assetItems,
selectableAssets,
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
} = defineProps<{
assets: AssetItem[]
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
}>()
const assetsStore = useAssetsStore()
const emit = defineEmits<{
(e: 'select-asset', asset: AssetItem): void
(e: 'select-asset', asset: AssetItem, assets?: AssetItem[]): void
(e: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void
}>()
@@ -162,9 +178,6 @@ const isQueuePanelV2Enabled = computed(() =>
)
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
)
@@ -176,13 +189,6 @@ const hoveredJob = computed(() =>
)
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)',
@@ -212,6 +218,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
function getStackCount(asset: AssetItem): number | undefined {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
return metadata.outputCount
}
if (Array.isArray(metadata?.allOutputs)) {
return metadata.allOutputs.length
}
return undefined
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',

View File

@@ -53,7 +53,7 @@
:show-generation-time-sort="activeTab === 'output'"
/>
<div
v-if="isQueuePanelV2Enabled"
v-if="isQueuePanelV2Enabled && !isInFolderView"
class="flex items-center justify-between px-2 py-2 2xl:px-4"
>
<span class="text-sm text-muted-foreground">
@@ -98,8 +98,11 @@
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:assets="displayAssets"
:asset-items="listViewAssetItems"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@@ -109,6 +112,7 @@
v-else
:assets="displayAssets"
:is-selected="isSelected"
:is-in-folder-view="isInFolderView"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
@@ -230,12 +234,13 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -243,12 +248,6 @@ import { ResultItemImpl, useQueueStore } from '@/stores/queueStore'
import { formatDuration, getMediaTypeFromFilename } from '@/utils/formatUtil'
import { cn } from '@/utils/tailwindUtil'
interface JobOutputItem {
filename: string
subfolder: string
type: string
}
const { t, n } = useI18n()
const commandStore = useCommandStore()
const queueStore = useQueueStore()
@@ -323,6 +322,7 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
@@ -392,7 +392,21 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
const {
assetItems: listViewAssetItems,
selectableAssets: listViewSelectableAssets,
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: computed(() => displayAssets.value)
})
const visibleAssets = computed(() => {
if (!isListView.value) return displayAssets.value
return listViewSelectableAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
@@ -412,7 +426,10 @@ const showEmptyState = computed(
activeJobsCount.value === 0
)
watch(displayAssets, (newAssets) => {
watch(visibleAssets, (newAssets) => {
// Alternative: keep hidden selections and surface them in UI; for now prune
// so selection stays consistent with what this view can act on.
reconcileSelection(newAssets)
if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value
@@ -430,7 +447,7 @@ watch(galleryActiveIndex, (index) => {
})
const galleryItems = computed(() => {
return displayAssets.value.map((asset) => {
return visibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({
filename: asset.name,
@@ -470,9 +487,10 @@ watch(
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, displayAssets.value)
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -557,7 +575,7 @@ const handleZoomClick = (asset: AssetItem) => {
}
currentGalleryAssetId.value = asset.id
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
const index = visibleAssets.value.findIndex((a) => a.id === asset.id)
if (index !== -1) {
galleryActiveIndex.value = index
}
@@ -570,7 +588,7 @@ const enterFolderView = async (asset: AssetItem) => {
return
}
const { promptId, allOutputs, executionTimeInSeconds, outputCount } = metadata
const { promptId, executionTimeInSeconds } = metadata
if (!promptId) {
console.warn('Missing required folder view data')
@@ -580,62 +598,21 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
// Determine which outputs to display
let outputsToDisplay = allOutputs ?? []
// If outputCount indicates more outputs than we have, fetch full outputs
const needsFullOutputs =
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsToDisplay.length < outputCount
if (needsFullOutputs) {
try {
const jobDetail = await getJobDetail(promptId)
if (jobDetail?.outputs) {
// Convert job outputs to ResultItemImpl array
outputsToDisplay = Object.entries(jobDetail.outputs).flatMap(
([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as JobOutputItem[])
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
.filter((r) => r.supportsPreview)
)
)
}
} catch (error) {
console.error('Failed to fetch job detail for folder view:', error)
outputsToDisplay = []
}
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
})
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
}
if (outputsToDisplay.length === 0) {
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = outputsToDisplay.map((output) => ({
id: `${output.nodeId}-${output.filename}`,
name: output.filename,
size: 0,
created_at: asset.created_at,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow: metadata.workflow
}
}))
folderAssets.value = folderItems
}
const exitFolderView = () => {

View File

@@ -74,13 +74,46 @@
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<slot name="actions" />
</div>
<div
v-if="typeof stackCount === 'number' && stackCount > 1"
class="relative z-1 flex shrink-0 items-center"
>
<Button
variant="secondary"
size="md"
class="gap-1 font-bold"
:aria-label="stackIndicatorLabel"
:aria-expanded="stackExpanded"
@click.stop="emit('stack-toggle')"
>
<i aria-hidden="true" class="icon-[lucide--layers] size-4" />
<span class="text-xs leading-none">{{ stackCount }}</span>
<i
aria-hidden="true"
:class="
cn(
stackExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-right]',
'size-3'
)
"
/>
</Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil'
const emit = defineEmits<{
'stack-toggle': []
}>()
const {
previewUrl,
previewAlt = '',
@@ -90,6 +123,9 @@ const {
iconWrapperClass,
primaryText,
secondaryText,
stackCount,
stackIndicatorLabel,
stackExpanded = false,
progressTotalPercent,
progressCurrentPercent
} = defineProps<{
@@ -101,6 +137,9 @@ const {
iconWrapperClass?: string
primaryText?: string
secondaryText?: string
stackCount?: number
stackIndicatorLabel?: string
stackExpanded?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
}>()

View File

@@ -23,6 +23,7 @@ vi.mock('@vueuse/core', async (importOriginal) => {
})
import { useAssetSelection } from './useAssetSelection'
import { useAssetSelectionStore } from './useAssetSelectionStore'
function createMockAssets(count: number): AssetItem[] {
return Array.from({ length: count }, (_, i) => ({
@@ -43,6 +44,79 @@ describe('useAssetSelection', () => {
mockMetaKey.value = false
})
describe('reconcileSelection', () => {
it('prunes selection to visible assets', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(1)
store.setLastSelectedAssetId('b')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(0)
expect(store.lastSelectedAssetId).toBe('b')
})
it('clears selection when no visible assets remain', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([])
expect(store.selectedAssetIds.size).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
it('recomputes the anchor index when assets reorder', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1], assets[0]])
expect(store.lastSelectedIndex).toBe(1)
expect(store.lastSelectedAssetId).toBe('a')
})
it('clears anchor when the anchored asset is no longer visible', () => {
const selection = useAssetSelection()
const store = useAssetSelectionStore()
const assets: AssetItem[] = [
{ id: 'a', name: 'a.png', tags: [] },
{ id: 'b', name: 'b.png', tags: [] }
]
store.setSelection(['a', 'b'])
store.setLastSelectedIndex(0)
store.setLastSelectedAssetId('a')
selection.reconcileSelection([assets[1]])
expect(Array.from(store.selectedAssetIds)).toEqual(['b'])
expect(store.lastSelectedIndex).toBe(-1)
expect(store.lastSelectedAssetId).toBeNull()
})
})
describe('handleAssetClick - normal click', () => {
it('selects single asset and clears previous selection', () => {
const { handleAssetClick, isSelected, selectedCount } =

View File

@@ -21,6 +21,25 @@ export function useAssetSelection() {
const metaKey = computed(() => isActive.value && metaKeyRaw.value)
const cmdOrCtrlKey = computed(() => ctrlKey.value || metaKey.value)
function setAnchor(index: number, assetId: string | null) {
selectionStore.setLastSelectedIndex(index)
selectionStore.setLastSelectedAssetId(assetId)
}
function syncAnchorFromAssets(assets: AssetItem[]) {
const anchorId = selectionStore.lastSelectedAssetId
const anchorIndex = anchorId
? assets.findIndex((asset) => asset.id === anchorId)
: -1
if (anchorIndex !== -1) {
selectionStore.setLastSelectedIndex(anchorIndex)
return
}
setAnchor(-1, null)
}
/**
* Handle asset click with modifier keys for selection
* @param asset The clicked asset
@@ -56,14 +75,14 @@ export function useAssetSelection() {
// Ctrl/Cmd + Click: Toggle individual selection
if (cmdOrCtrlKey.value) {
selectionStore.toggleSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
return
}
// Normal Click: Single selection
selectionStore.clearSelection()
selectionStore.addToSelection(assetId)
selectionStore.setLastSelectedIndex(index)
setAnchor(index, assetId)
}
/**
@@ -73,7 +92,8 @@ export function useAssetSelection() {
const allIds = allAssets.map((a) => a.id)
selectionStore.setSelection(allIds)
if (allAssets.length > 0) {
selectionStore.setLastSelectedIndex(allAssets.length - 1)
const lastIndex = allAssets.length - 1
setAnchor(lastIndex, allAssets[lastIndex].id)
}
}
@@ -84,6 +104,39 @@ export function useAssetSelection() {
return allAssets.filter((asset) => selectionStore.isSelected(asset.id))
}
function reconcileSelection(assets: AssetItem[]) {
if (selectionStore.selectedAssetIds.size === 0) {
return
}
if (assets.length === 0) {
selectionStore.clearSelection()
return
}
const visibleIds = new Set(assets.map((asset) => asset.id))
const nextSelectedIds: string[] = []
for (const id of selectionStore.selectedAssetIds) {
if (visibleIds.has(id)) {
nextSelectedIds.push(id)
}
}
if (nextSelectedIds.length === selectionStore.selectedAssetIds.size) {
syncAnchorFromAssets(assets)
return
}
if (nextSelectedIds.length === 0) {
selectionStore.clearSelection()
return
}
selectionStore.setSelection(nextSelectedIds)
syncAnchorFromAssets(assets)
}
/**
* Get the output count for a single asset
* Same logic as in AssetsSidebarTab.vue
@@ -113,7 +166,7 @@ export function useAssetSelection() {
function deactivate() {
isActive.value = false
// Reset selection state to ensure clean state when deactivated
selectionStore.reset()
selectionStore.clearSelection()
}
return {
@@ -128,10 +181,9 @@ export function useAssetSelection() {
selectAll,
clearSelection: () => selectionStore.clearSelection(),
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
reset: () => selectionStore.reset(),
// Lifecycle management
activate,
deactivate,

View File

@@ -68,6 +68,16 @@ describe('useAssetSelectionStore', () => {
expect(store.selectedCount).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
})
it('resets lastSelectedAssetId', () => {
const store = useAssetSelectionStore()
store.addToSelection('asset-1')
store.setLastSelectedAssetId('asset-1')
store.clearSelection()
expect(store.lastSelectedAssetId).toBe(null)
})
})
describe('toggleSelection', () => {
@@ -106,19 +116,6 @@ describe('useAssetSelectionStore', () => {
})
})
describe('reset', () => {
it('clears selection and resets index', () => {
const store = useAssetSelectionStore()
store.addToSelection('asset-1')
store.setLastSelectedIndex(5)
store.reset()
expect(store.selectedCount).toBe(0)
expect(store.lastSelectedIndex).toBe(-1)
})
})
describe('computed properties', () => {
it('hasSelection returns true when items are selected', () => {
const store = useAssetSelectionStore()

View File

@@ -5,6 +5,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
// State
const selectedAssetIds = ref<Set<string>>(new Set())
const lastSelectedIndex = ref<number>(-1)
const lastSelectedAssetId = ref<string | null>(null)
// Getters
const selectedCount = computed(() => selectedAssetIds.value.size)
@@ -27,6 +28,7 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
function clearSelection() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
lastSelectedAssetId.value = null
}
function toggleSelection(assetId: string) {
@@ -45,16 +47,15 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
lastSelectedIndex.value = index
}
// Reset function for cleanup
function reset() {
selectedAssetIds.value.clear()
lastSelectedIndex.value = -1
function setLastSelectedAssetId(assetId: string | null) {
lastSelectedAssetId.value = assetId
}
return {
// State
selectedAssetIds: computed(() => selectedAssetIds.value),
lastSelectedIndex: computed(() => lastSelectedIndex.value),
lastSelectedAssetId: computed(() => lastSelectedAssetId.value),
// Getters
selectedCount,
@@ -69,6 +70,6 @@ export const useAssetSelectionStore = defineStore('assetSelection', () => {
toggleSelection,
isSelected,
setLastSelectedIndex,
reset
setLastSelectedAssetId
}
})

View File

@@ -45,7 +45,7 @@ export function useMediaAssetActions() {
): Promise<void> => {
if (assetType === 'output') {
const promptId =
asset.id || getOutputAssetMetadata(asset.user_metadata)?.promptId
getOutputAssetMetadata(asset.user_metadata)?.promptId || asset.id
if (!promptId) {
throw new Error('Unable to extract prompt ID from asset')
}
@@ -138,9 +138,10 @@ export function useMediaAssetActions() {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
// Try asset.id first (OSS), then fall back to metadata (Cloud)
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const promptId = targetAsset.id || metadata?.promptId
const promptId =
metadata?.promptId ||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
if (!promptId) {
toast.add({

View File

@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
const mocks = vi.hoisted(() => ({
resolveOutputAssetItems: vi.fn()
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof OutputAssetUtil>()
return {
...actual,
resolveOutputAssetItems: mocks.resolveOutputAssetItems
}
})
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason?: unknown) => void
}
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((resolveFn, rejectFn) => {
resolve = resolveFn
reject = rejectFn
})
return { promise, resolve, reject }
}
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'parent.png',
tags: [],
created_at: '2025-01-01T00:00:00.000Z',
user_metadata: {
promptId: 'prompt-1',
nodeId: 'node-1',
subfolder: 'outputs'
},
...overrides
}
}
describe('useOutputStacks', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('expands stacks and exposes children as selectable assets', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const childA = createAsset({
id: 'child-a',
name: 'child-a.png',
user_metadata: undefined
})
const childB = createAsset({
id: 'child-b',
name: 'child-b.png',
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([childA, childB])
const { assetItems, isStackExpanded, selectableAssets, toggleStack } =
useOutputStacks({ assets: ref([parent]) })
await toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ promptId: 'prompt-1' }),
{
createdAt: parent.created_at,
excludeOutputKey: 'node-1-outputs-parent.png'
}
)
expect(isStackExpanded(parent)).toBe(true)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
parent.id,
childA.id,
childB.id
])
expect(assetItems.value[1]).toMatchObject({
asset: childA,
isChild: true
})
expect(assetItems.value[2]).toMatchObject({
asset: childB,
isChild: true
})
expect(selectableAssets.value).toEqual([parent, childA, childB])
})
it('collapses an expanded stack when toggled again', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const child = createAsset({
id: 'child',
name: 'child.png',
user_metadata: undefined
})
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([child])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
})
it('ignores assets without stack metadata', async () => {
const asset = createAsset({
id: 'no-meta',
name: 'no-meta.png',
user_metadata: undefined
})
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([asset])
})
await toggleStack(asset)
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
expect(isStackExpanded(asset)).toBe(false)
expect(assetItems.value).toHaveLength(1)
expect(assetItems.value[0].asset).toMatchObject(asset)
})
it('does not expand when no children are resolved', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
vi.mocked(mocks.resolveOutputAssetItems).mockResolvedValue([])
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
})
it('does not expand when resolving children throws', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(mocks.resolveOutputAssetItems).mockRejectedValue(
new Error('resolve failed')
)
const { assetItems, isStackExpanded, toggleStack } = useOutputStacks({
assets: ref([parent])
})
await toggleStack(parent)
expect(isStackExpanded(parent)).toBe(false)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([parent.id])
errorSpy.mockRestore()
})
it('guards against duplicate loads while a stack is resolving', async () => {
const parent = createAsset({ id: 'parent', name: 'parent.png' })
const child = createAsset({
id: 'child',
name: 'child.png',
user_metadata: undefined
})
const deferred = createDeferred<AssetItem[]>()
vi.mocked(mocks.resolveOutputAssetItems).mockReturnValue(deferred.promise)
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([parent])
})
const firstToggle = toggleStack(parent)
const secondToggle = toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
deferred.resolve([child])
await firstToggle
await secondToggle
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
parent.id,
child.id
])
})
})

View File

@@ -0,0 +1,138 @@
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getOutputKey,
resolveOutputAssetItems
} from '@/platform/assets/utils/outputAssetUtil'
export type OutputStackListItem = {
key: string
asset: AssetItem
isChild?: boolean
}
type UseOutputStacksOptions = {
assets: Ref<AssetItem[]>
}
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
const expandedStackPromptIds = ref<Set<string>>(new Set())
const stackChildrenByPromptId = ref<Record<string, AssetItem[]>>({})
const loadingStackPromptIds = ref<Set<string>>(new Set())
const assetItems = computed<OutputStackListItem[]>(() => {
const items: OutputStackListItem[] = []
for (const asset of assets.value) {
const promptId = getStackPromptId(asset)
items.push({
key: `asset-${asset.id}`,
asset
})
if (!promptId || !expandedStackPromptIds.value.has(promptId)) {
continue
}
const children = stackChildrenByPromptId.value[promptId] ?? []
for (const child of children) {
items.push({
key: `asset-${child.id}`,
asset: child,
isChild: true
})
}
}
return items
})
const selectableAssets = computed(() =>
assetItems.value.map((item) => item.asset)
)
function getStackPromptId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.promptId ?? null
}
function isStackExpanded(asset: AssetItem): boolean {
const promptId = getStackPromptId(asset)
if (!promptId) return false
return expandedStackPromptIds.value.has(promptId)
}
async function toggleStack(asset: AssetItem) {
const promptId = getStackPromptId(asset)
if (!promptId) return
if (expandedStackPromptIds.value.has(promptId)) {
const next = new Set(expandedStackPromptIds.value)
next.delete(promptId)
expandedStackPromptIds.value = next
return
}
if (!stackChildrenByPromptId.value[promptId]?.length) {
if (loadingStackPromptIds.value.has(promptId)) {
return
}
const nextLoading = new Set(loadingStackPromptIds.value)
nextLoading.add(promptId)
loadingStackPromptIds.value = nextLoading
const children = await resolveStackChildren(asset)
const afterLoading = new Set(loadingStackPromptIds.value)
afterLoading.delete(promptId)
loadingStackPromptIds.value = afterLoading
if (!children.length) {
return
}
stackChildrenByPromptId.value = {
...stackChildrenByPromptId.value,
[promptId]: children
}
}
const nextExpanded = new Set(expandedStackPromptIds.value)
nextExpanded.add(promptId)
expandedStackPromptIds.value = nextExpanded
}
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
return []
}
const excludeOutputKey =
getOutputKey({
nodeId: metadata.nodeId,
subfolder: metadata.subfolder,
filename: asset.name
}) ?? undefined
try {
return await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at,
excludeOutputKey
})
} catch (error) {
console.error('Failed to resolve stack children:', error)
return []
}
}
return {
assetItems,
selectableAssets,
isStackExpanded,
toggleStack
}
}

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import { resolveOutputAssetItems } from './outputAssetUtil'
const mocks = vi.hoisted(() => ({
getJobDetail: vi.fn(),
getPreviewableOutputsFromJobDetail: vi.fn()
}))
vi.mock('@/services/jobOutputCache', () => ({
getJobDetail: mocks.getJobDetail,
getPreviewableOutputsFromJobDetail: mocks.getPreviewableOutputsFromJobDetail
}))
type OutputOverrides = Partial<{
filename: string
subfolder: string
nodeId: string
url: string
}>
function createOutput(overrides: OutputOverrides = {}): ResultItemImpl {
return {
filename: 'file.png',
subfolder: 'sub',
nodeId: '1',
url: 'https://example.com/file.png',
...overrides
} as ResultItemImpl
}
describe('resolveOutputAssetItems', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('maps outputs and excludes a composite output key', 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 metadata: OutputAssetMetadata = {
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5,
outputCount: 2,
allOutputs: [outputA, outputB]
}
const results = await resolveOutputAssetItems(metadata, {
createdAt: '2025-01-01T00:00:00.000Z',
excludeOutputKey: '2-sub-b.png'
})
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
expect(results[0]).toEqual(
expect.objectContaining({
id: 'prompt-1-1-sub-a.png',
name: 'a.png',
created_at: '2025-01-01T00:00:00.000Z',
tags: ['output'],
preview_url: 'https://example.com/a.png'
})
)
expect(results[0].user_metadata).toEqual(
expect.objectContaining({
promptId: 'prompt-1',
nodeId: '1',
subfolder: 'sub',
executionTimeInSeconds: 12.5
})
)
})
it('loads full outputs when metadata indicates more outputs', async () => {
const previewOutput = createOutput({
filename: 'preview.png',
nodeId: '1',
url: 'https://example.com/preview.png'
})
const fullOutput = createOutput({
filename: 'full.png',
nodeId: '2',
url: 'https://example.com/full.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-2',
nodeId: '1',
subfolder: 'sub',
outputCount: 3,
allOutputs: [previewOutput]
}
const jobDetail = { id: 'job-1' }
mocks.getJobDetail.mockResolvedValue(jobDetail)
mocks.getPreviewableOutputsFromJobDetail.mockReturnValue([
fullOutput,
previewOutput
])
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).toHaveBeenCalledWith('prompt-2')
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)
expect(results.map((asset) => asset.name)).toEqual([
'full.png',
'preview.png'
])
})
it('keeps root outputs with empty subfolders', async () => {
const output = createOutput({
filename: 'root.png',
nodeId: '1',
subfolder: '',
url: 'https://example.com/root.png'
})
const metadata: OutputAssetMetadata = {
promptId: 'prompt-root',
nodeId: '1',
subfolder: '',
outputCount: 1,
allOutputs: [output]
}
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).not.toHaveBeenCalled()
expect(results).toHaveLength(1)
const [asset] = results
if (!asset) {
throw new Error('Expected a root output asset')
}
expect(asset.id).toBe('prompt-root-1--root.png')
if (!asset.user_metadata) {
throw new Error('Expected output metadata')
}
expect(asset.user_metadata.subfolder).toBe('')
})
})

View File

@@ -0,0 +1,109 @@
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
getJobDetail,
getPreviewableOutputsFromJobDetail
} from '@/services/jobOutputCache'
import type { ResultItemImpl } from '@/stores/queueStore'
type OutputAssetMapOptions = {
promptId: string
outputs: readonly ResultItemImpl[]
createdAt?: string
executionTimeInSeconds?: number
workflow?: OutputAssetMetadata['workflow']
excludeOutputKey?: string
}
type ResolveOutputAssetItemsOptions = {
createdAt?: string
excludeOutputKey?: string
}
type OutputKeyParts = {
nodeId?: string | number | null
subfolder?: string | null
filename?: string | null
}
function shouldLoadFullOutputs(
outputCount: OutputAssetMetadata['outputCount'],
outputsLength: number
): boolean {
return (
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsLength < outputCount
)
}
export function getOutputKey({
nodeId,
subfolder,
filename
}: OutputKeyParts): string | null {
if (nodeId == null || subfolder == null || !filename) {
return null
}
return `${nodeId}-${subfolder}-${filename}`
}
function mapOutputsToAssetItems({
promptId,
outputs,
createdAt,
executionTimeInSeconds,
workflow,
excludeOutputKey
}: OutputAssetMapOptions): AssetItem[] {
const createdAtValue = createdAt ?? new Date().toISOString()
return outputs.reduce<AssetItem[]>((items, output) => {
const outputKey = getOutputKey(output)
if (!output.filename || !outputKey || outputKey === excludeOutputKey) {
return items
}
items.push({
id: `${promptId}-${outputKey}`,
name: output.filename,
size: 0,
created_at: createdAtValue,
tags: ['output'],
preview_url: output.url,
user_metadata: {
promptId,
nodeId: output.nodeId,
subfolder: output.subfolder,
executionTimeInSeconds,
workflow
}
})
return items
}, [])
}
export async function resolveOutputAssetItems(
metadata: OutputAssetMetadata,
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
): Promise<AssetItem[]> {
let outputsToDisplay = metadata.allOutputs ?? []
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
const jobDetail = await getJobDetail(metadata.promptId)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
if (previewableOutputs.length) {
outputsToDisplay = previewableOutputs
}
}
return mapOutputsToAssetItems({
promptId: metadata.promptId,
outputs: outputsToDisplay,
createdAt,
executionTimeInSeconds: metadata.executionTimeInSeconds,
workflow: metadata.workflow,
excludeOutputKey
})
}

View File

@@ -195,6 +195,89 @@ describe('jobOutputCache', () => {
})
})
describe('getPreviewableOutputsFromJobDetail', () => {
it('returns empty array when job detail or outputs are missing', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
expect(getPreviewableOutputsFromJobDetail(undefined)).toEqual([])
const jobDetail: JobDetail = {
id: 'job-empty',
status: 'completed',
create_time: Date.now(),
priority: 0
}
expect(getPreviewableOutputsFromJobDetail(jobDetail)).toEqual([])
})
it('maps previewable outputs and skips animated/text entries', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-previewable',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-1': {
images: [
{ filename: 'image.png', subfolder: '', type: 'output' },
{ filename: 'image.webp', subfolder: '', type: 'temp' }
],
animated: [true],
text: 'hello'
},
'node-2': {
video: [{ filename: 'clip.mp4', subfolder: '', type: 'output' }],
audio: [{ filename: 'sound.mp3', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result).toHaveLength(4)
expect(result.map((item) => item.filename).sort()).toEqual(
['image.png', 'image.webp', 'clip.mp4', 'sound.mp3'].sort()
)
const image = result.find((item) => item.filename === 'image.png')
const video = result.find((item) => item.filename === 'clip.mp4')
const { ResultItemImpl: ResultItemImplClass } =
await import('@/stores/queueStore')
expect(image).toBeInstanceOf(ResultItemImplClass)
expect(image?.nodeId).toBe('node-1')
expect(image?.mediaType).toBe('images')
expect(video?.nodeId).toBe('node-2')
expect(video?.mediaType).toBe('video')
})
it('filters non-previewable outputs and non-object items', async () => {
const { getPreviewableOutputsFromJobDetail } =
await import('@/services/jobOutputCache')
const jobDetail: JobDetail = {
id: 'job-filter',
status: 'completed',
create_time: Date.now(),
priority: 0,
outputs: {
'node-3': {
images: [{ filename: 'valid.png', subfolder: '', type: 'output' }],
text: ['not-object'],
unknown: [{ filename: 'data.bin', subfolder: '', type: 'output' }]
}
}
}
const result = getPreviewableOutputsFromJobDetail(jobDetail)
expect(result.map((item) => item.filename)).toEqual(['valid.png'])
})
})
describe('getJobDetail', () => {
it('fetches and caches job detail', async () => {
const jobId = uniqueId('job')

View File

@@ -11,6 +11,8 @@ import QuickLRU from '@alloc/quick-lru'
import type { JobDetail } from '@/platform/remote/comfyui/jobs/jobTypes'
import { extractWorkflow } from '@/platform/remote/comfyui/jobs/fetchJobs'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { resultItemType } from '@/schemas/apiSchema'
import type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -75,6 +77,75 @@ export async function getOutputsForTask(
}
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
const resultItems = Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, _]) => mediaType !== 'animated')
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return items.filter(isResultItemLike).map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
return ResultItemImpl.filterPreviewable(resultItems)
}
function isResultItemLike(item: unknown): item is ResultItem {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return false
}
const candidate = item as Record<string, unknown>
if (
candidate.filename !== undefined &&
typeof candidate.filename !== 'string'
) {
return false
}
if (
candidate.subfolder !== undefined &&
typeof candidate.subfolder !== 'string'
) {
return false
}
if (
candidate.type !== undefined &&
!resultItemType.safeParse(candidate.type).success
) {
return false
}
if (
candidate.filename === undefined &&
candidate.subfolder === undefined &&
candidate.type === undefined
) {
return false
}
return true
}
export function getPreviewableOutputsFromJobDetail(
jobDetail?: JobDetail
): ResultItemImpl[] {
return getPreviewableOutputs(jobDetail?.outputs)
}
// ===== Job Detail Caching =====
export async function getJobDetail(