feat: add output stacks to list view

This commit is contained in:
Benjamin Lu
2026-01-24 08:07:25 -08:00
parent 2782d316a5
commit 4ae93806c9
6 changed files with 131 additions and 30 deletions

View File

@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { computed } 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: computed(() => 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
@@ -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="
@@ -80,10 +85,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="toggleStack(item.asset)"
>
<template v-if="hoveredAssetId === item.asset.id" #actions>
<Button
@@ -111,6 +120,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'
@@ -125,17 +135,23 @@ import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil'
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) => void
assetType?: 'input' | 'output'
}>()
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
}>()
@@ -144,9 +160,6 @@ const { t } = useI18n()
const { jobItems } = useJobList()
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))
)
@@ -158,13 +171,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)',
@@ -194,6 +200,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

@@ -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"
@@ -225,6 +228,7 @@ 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'
@@ -309,6 +313,7 @@ const {
hasSelection,
clearSelection,
getSelectedAssets,
reconcileSelection,
getOutputCount,
getTotalOutputCount,
activate: activateSelection,
@@ -378,7 +383,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
@@ -398,7 +417,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
@@ -416,7 +438,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,
@@ -456,9 +478,10 @@ 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 ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
}
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -531,7 +554,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
}

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 || undefined"
: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

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

@@ -5,7 +5,7 @@ import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataS
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
type OutputStackListItem = {
export type OutputStackListItem = {
key: string
asset: AssetItem
isChild?: boolean