Add stacked functionality to list view

This commit is contained in:
Benjamin Lu
2026-01-23 14:35:33 -08:00
parent 8a5023831d
commit 282f94ad7d
6 changed files with 312 additions and 78 deletions

View File

@@ -72,7 +72,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="
@@ -82,10 +87,12 @@
: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="toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
@@ -104,7 +111,7 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import VirtualGrid from '@/components/common/VirtualGrid.vue'
@@ -113,9 +120,18 @@ 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 { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema';
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema';
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
mapOutputsToAssetItems,
shouldLoadFullOutputs
} from '@/platform/assets/utils/outputAssetUtil'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
import {
getJobDetail,
getPreviewableOutputsFromJobDetail
} from '@/services/jobOutputCache'
import { isActiveJobState } from '@/utils/queueUtil'
import {
formatDuration,
@@ -137,17 +153,25 @@ const {
}>()
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
(e: 'assets-change', assets: AssetItem[]): void
}>()
const { t } = useI18n()
const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null)
const expandedStackPromptIds = ref<Set<string>>(new Set())
const stackChildrenByPromptId = ref<Record<string, AssetItem[]>>({})
const loadingStackPromptIds = ref<Set<string>>(new Set())
type AssetListItem = { key: string; asset: AssetItem }
type AssetListItem = {
key: string
asset: AssetItem
isChild?: boolean
}
const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state))
@@ -160,11 +184,43 @@ const hoveredJob = computed(() =>
)
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
const assetItems = computed<AssetListItem[]>(() => {
const items: AssetListItem[] = []
for (const asset of assets) {
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)
)
watch(
selectableAssets,
(nextAssets) => {
emit('assets-change', nextAssets)
},
{ immediate: true }
)
const listGridStyle = {
@@ -196,6 +252,11 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
function getStackPromptId(asset: AssetItem): string | null {
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.promptId ?? null
}
function getStackCount(asset: AssetItem): number | undefined {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
@@ -209,6 +270,89 @@ function getStackCount(asset: AssetItem): number | undefined {
return undefined
}
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 resolveStackOutputs(metadata: OutputAssetMetadata) {
const outputsToDisplay = metadata.allOutputs ?? []
if (!shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
return outputsToDisplay
}
try {
const jobDetail = await getJobDetail(metadata.promptId)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
return previewableOutputs.length ? previewableOutputs : outputsToDisplay
} catch (error) {
console.error('Failed to fetch job detail for stack children:', error)
return outputsToDisplay
}
}
async function resolveStackChildren(asset: AssetItem): Promise<AssetItem[]> {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
return []
}
const outputsToDisplay = await resolveStackOutputs(metadata)
if (!outputsToDisplay.length) {
return []
}
return mapOutputsToAssetItems({
promptId: metadata.promptId,
outputs: outputsToDisplay,
createdAt: asset.created_at,
executionTimeInSeconds: metadata.executionTimeInSeconds,
workflow: metadata.workflow,
excludeFilename: asset.name
})
}
function getAssetCardClass(selected: boolean): string {
return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover',

View File

@@ -102,6 +102,7 @@
:is-selected="isSelected"
:asset-type="activeTab"
@select-asset="handleAssetSelect"
@assets-change="handleListViewAssetsChange"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
/>
@@ -225,12 +226,20 @@ 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 { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema';
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema';
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import {
mapOutputsToAssetItems,
shouldLoadFullOutputs
} from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { getJobDetail } from '@/services/jobOutputCache'
import {
getJobDetail,
getPreviewableOutputsFromJobDetail
} from '@/services/jobOutputCache'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import { useExecutionStore } from '@/stores/executionStore'
@@ -238,12 +247,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()
@@ -384,7 +387,13 @@ const displayAssets = computed(() => {
return filteredAssets.value
})
const selectedAssets = computed(() => getSelectedAssets(displayAssets.value))
const listViewAssets = ref<AssetItem[]>([])
const selectionAssets = computed(() =>
isListView.value ? listViewAssets.value : displayAssets.value
)
const selectedAssets = computed(() => getSelectedAssets(selectionAssets.value))
const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
@@ -462,9 +471,14 @@ watch(
{ immediate: true }
)
const handleAssetSelect = (asset: AssetItem) => {
const index = displayAssets.value.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, displayAssets.value)
const handleAssetSelect = (asset: AssetItem, assets?: AssetItem[]) => {
const assetList = assets ?? selectionAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
}
const handleListViewAssetsChange = (assets: AssetItem[]) => {
listViewAssets.value = assets
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -543,6 +557,17 @@ const handleZoomClick = (asset: AssetItem) => {
}
}
async function resolveFolderOutputs(metadata: OutputAssetMetadata) {
const outputsToDisplay = metadata.allOutputs ?? []
if (!shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
return outputsToDisplay
}
const jobDetail = await getJobDetail(metadata.promptId)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
return previewableOutputs.length ? previewableOutputs : outputsToDisplay
}
const enterFolderView = async (asset: AssetItem) => {
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
@@ -550,7 +575,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')
@@ -560,62 +585,20 @@ 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 = []
}
}
const outputsToDisplay = await resolveFolderOutputs(metadata)
if (outputsToDisplay.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 = mapOutputsToAssetItems({
promptId,
outputs: outputsToDisplay,
createdAt: asset.created_at,
executionTimeInSeconds,
workflow: metadata.workflow
})
}
const exitFolderView = () => {

View File

@@ -84,10 +84,21 @@
size="md"
class="gap-1 font-bold"
:aria-label="stackIndicatorLabel || undefined"
@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="icon-[lucide--chevron-down] size-3" />
<i
aria-hidden="true"
:class="
cn(
stackExpanded
? 'icon-[lucide--chevron-down]'
: 'icon-[lucide--chevron-right]',
'size-3'
)
"
/>
</Button>
</div>
</div>
@@ -98,6 +109,10 @@ 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 = '',
@@ -109,6 +124,7 @@ const {
secondaryText,
stackCount,
stackIndicatorLabel,
stackExpanded = false,
progressTotalPercent,
progressCurrentPercent
} = defineProps<{
@@ -122,6 +138,7 @@ const {
secondaryText?: string
stackCount?: number
stackIndicatorLabel?: string
stackExpanded?: boolean
progressTotalPercent?: number
progressCurrentPercent?: number
}>()

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')
}
@@ -203,9 +203,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,52 @@
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
type OutputAssetMapOptions = {
promptId: string
outputs: readonly ResultItemImpl[]
createdAt?: string
executionTimeInSeconds?: number
workflow?: OutputAssetMetadata['workflow']
excludeFilename?: string
}
export function shouldLoadFullOutputs(
outputCount: OutputAssetMetadata['outputCount'],
outputsLength: number
): boolean {
return (
typeof outputCount === 'number' &&
outputCount > 1 &&
outputsLength < outputCount
)
}
export function mapOutputsToAssetItems({
promptId,
outputs,
createdAt,
executionTimeInSeconds,
workflow,
excludeFilename
}: OutputAssetMapOptions): AssetItem[] {
const createdAtValue = createdAt ?? new Date().toISOString()
return outputs
.filter((output) => output.filename && output.filename !== excludeFilename)
.map((output) => ({
id: `${promptId}-${output.nodeId}-${output.filename}`,
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
}
}))
}

View File

@@ -11,6 +11,7 @@ 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 type { ResultItem, TaskOutput } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { ResultItemImpl } from '@/stores/queueStore'
import type { TaskItemImpl } from '@/stores/queueStore'
@@ -75,6 +76,42 @@ export async function getOutputsForTask(
}
}
function mapTaskOutputToResultItems(outputs: TaskOutput): ResultItemImpl[] {
return Object.entries(outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs)
.filter(([mediaType, items]) => mediaType !== 'animated' && items)
.flatMap(([mediaType, items]) => {
if (!Array.isArray(items)) {
return []
}
return (items as ResultItem[])
.filter((item) => typeof item === 'object' && item !== null)
.map(
(item) =>
new ResultItemImpl({
...item,
nodeId,
mediaType
})
)
})
)
}
function getPreviewableOutputsFromTaskOutput(
outputs?: TaskOutput
): ResultItemImpl[] {
if (!outputs) return []
return ResultItemImpl.filterPreviewable(mapTaskOutputToResultItems(outputs))
}
export function getPreviewableOutputsFromJobDetail(
jobDetail?: JobDetail
): ResultItemImpl[] {
return getPreviewableOutputsFromTaskOutput(jobDetail?.outputs)
}
// ===== Job Detail Caching =====
export async function getJobDetail(