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

View File

@@ -40,7 +40,7 @@
</div> </div>
<div <div
v-if="assets.length" v-if="assetItems.length"
:class="cn('px-2', activeJobItems.length && 'mt-2')" :class="cn('px-2', activeJobItems.length && 'mt-2')"
> >
<div <div
@@ -72,7 +72,12 @@
type: getMediaTypeFromFilename(item.asset.name) 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-url="item.asset.preview_url"
:preview-alt="item.asset.name" :preview-alt="item.asset.name"
:icon-name=" :icon-name="
@@ -80,10 +85,14 @@
" "
:primary-text="getAssetPrimaryText(item.asset)" :primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(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)" @mouseenter="onAssetEnter(item.asset.id)"
@mouseleave="onAssetLeave(item.asset.id)" @mouseleave="onAssetLeave(item.asset.id)"
@contextmenu.prevent.stop="emit('context-menu', $event, item.asset)" @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> <template v-if="hoveredAssetId === item.asset.id" #actions>
<Button <Button
@@ -111,6 +120,7 @@ import { useJobActions } from '@/composables/queue/useJobActions'
import type { JobListItem } from '@/composables/queue/useJobList' import type { JobListItem } from '@/composables/queue/useJobList'
import { useJobList } from '@/composables/queue/useJobList' import { useJobList } from '@/composables/queue/useJobList'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue' import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil' import { iconForMediaType } from '@/platform/assets/utils/mediaIconUtil'
@@ -125,17 +135,23 @@ import { iconForJobState } from '@/utils/queueDisplay'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const { const {
assets, assetItems,
selectableAssets,
isSelected, isSelected,
isStackExpanded,
toggleStack,
assetType = 'output' assetType = 'output'
} = defineProps<{ } = defineProps<{
assets: AssetItem[] assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => void
assetType?: 'input' | 'output' assetType?: 'input' | 'output'
}>() }>()
const emit = defineEmits<{ 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: 'context-menu', event: MouseEvent, asset: AssetItem): void
(e: 'approach-end'): void (e: 'approach-end'): void
}>() }>()
@@ -144,9 +160,6 @@ const { t } = useI18n()
const { jobItems } = useJobList() const { jobItems } = useJobList()
const hoveredJobId = ref<string | null>(null) const hoveredJobId = ref<string | null>(null)
const hoveredAssetId = ref<string | null>(null) const hoveredAssetId = ref<string | null>(null)
type AssetListItem = { key: string; asset: AssetItem }
const activeJobItems = computed(() => const activeJobItems = computed(() =>
jobItems.value.filter((item) => isActiveJobState(item.state)) jobItems.value.filter((item) => isActiveJobState(item.state))
) )
@@ -158,13 +171,6 @@ const hoveredJob = computed(() =>
) )
const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob) const { cancelAction, canCancelJob, runCancelJob } = useJobActions(hoveredJob)
const assetItems = computed<AssetListItem[]>(() =>
assets.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listGridStyle = { const listGridStyle = {
display: 'grid', display: 'grid',
gridTemplateColumns: 'minmax(0, 1fr)', gridTemplateColumns: 'minmax(0, 1fr)',
@@ -194,6 +200,19 @@ function getAssetSecondaryText(asset: AssetItem): string {
return '' 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 { function getAssetCardClass(selected: boolean): string {
return cn( return cn(
'w-full text-text-primary transition-colors hover:bg-secondary-background-hover', '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"> <div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView <AssetsSidebarListView
v-if="isListView" v-if="isListView"
:assets="displayAssets" :asset-items="listViewAssetItems"
:is-selected="isSelected" :is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab" :asset-type="activeTab"
@select-asset="handleAssetSelect" @select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu" @context-menu="handleAssetContextMenu"
@@ -225,6 +228,7 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection' import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions' import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering' import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema' import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema' import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
@@ -309,6 +313,7 @@ const {
hasSelection, hasSelection,
clearSelection, clearSelection,
getSelectedAssets, getSelectedAssets,
reconcileSelection,
getOutputCount, getOutputCount,
getTotalOutputCount, getTotalOutputCount,
activate: activateSelection, activate: activateSelection,
@@ -378,7 +383,21 @@ const displayAssets = computed(() => {
return filteredAssets.value 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( const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1 () => hasSelection.value && selectedAssets.value.length > 1
@@ -398,7 +417,10 @@ const showEmptyState = computed(
activeJobsCount.value === 0 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) { if (currentGalleryAssetId.value && galleryActiveIndex.value !== -1) {
const newIndex = newAssets.findIndex( const newIndex = newAssets.findIndex(
(asset) => asset.id === currentGalleryAssetId.value (asset) => asset.id === currentGalleryAssetId.value
@@ -416,7 +438,7 @@ watch(galleryActiveIndex, (index) => {
}) })
const galleryItems = computed(() => { const galleryItems = computed(() => {
return displayAssets.value.map((asset) => { return visibleAssets.value.map((asset) => {
const mediaType = getMediaTypeFromFilename(asset.name) const mediaType = getMediaTypeFromFilename(asset.name)
const resultItem = new ResultItemImpl({ const resultItem = new ResultItemImpl({
filename: asset.name, filename: asset.name,
@@ -456,9 +478,10 @@ watch(
{ immediate: true } { immediate: true }
) )
const handleAssetSelect = (asset: AssetItem) => { const handleAssetSelect = (asset: AssetItem, assets?: AssetItem[]) => {
const index = displayAssets.value.findIndex((a) => a.id === asset.id) const assetList = assets ?? visibleAssets.value
handleAssetClick(asset, index, displayAssets.value) const index = assetList.findIndex((a) => a.id === asset.id)
handleAssetClick(asset, index, assetList)
} }
function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) { function handleAssetContextMenu(event: MouseEvent, asset: AssetItem) {
@@ -531,7 +554,7 @@ const handleZoomClick = (asset: AssetItem) => {
} }
currentGalleryAssetId.value = asset.id 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) { if (index !== -1) {
galleryActiveIndex.value = index galleryActiveIndex.value = index
} }

View File

@@ -74,13 +74,46 @@
<div v-if="$slots.actions" class="relative z-1 flex items-center gap-2"> <div v-if="$slots.actions" class="relative z-1 flex items-center gap-2">
<slot name="actions" /> <slot name="actions" />
</div> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useProgressBarBackground } from '@/composables/useProgressBarBackground' import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
const emit = defineEmits<{
'stack-toggle': []
}>()
const { const {
previewUrl, previewUrl,
previewAlt = '', previewAlt = '',
@@ -90,6 +123,9 @@ const {
iconWrapperClass, iconWrapperClass,
primaryText, primaryText,
secondaryText, secondaryText,
stackCount,
stackIndicatorLabel,
stackExpanded = false,
progressTotalPercent, progressTotalPercent,
progressCurrentPercent progressCurrentPercent
} = defineProps<{ } = defineProps<{
@@ -101,6 +137,9 @@ const {
iconWrapperClass?: string iconWrapperClass?: string
primaryText?: string primaryText?: string
secondaryText?: string secondaryText?: string
stackCount?: number
stackIndicatorLabel?: string
stackExpanded?: boolean
progressTotalPercent?: number progressTotalPercent?: number
progressCurrentPercent?: number progressCurrentPercent?: number
}>() }>()

View File

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

View File

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