fix: show skeleton loading state in asset folder view (#8979)

## Description

When clicking a multi-output job to enter folder view,
`resolveOutputAssetItems` fetches job details asynchronously. During
this fetch, the panel showed "No generated files found" because there
was no loading state for the folder resolution—only the media list fetch
had one.

This replaces the empty state flash with skeleton cards that match the
asset grid layout, using the known output count from metadata to render
the correct number of placeholders.

Supersedes #8960.

### Changes

- **Add shadcn/vue `Skeleton` component**
(`src/components/ui/skeleton/Skeleton.vue`)
- **Use `useAsyncState`** from VueUse to track folder asset resolution,
providing `isLoading` automatically
- **Wire `folderLoading`** into `showLoadingState` and `showEmptyState`
computeds
- **Replace `ProgressSpinner`** with a skeleton grid that mirrors the
asset card layout
- **Use `metadata.outputCount`** to predict skeleton count; falls back
to 6

### Before / After

| Before | After |
|--------|-------|
| "No generated files found" flash | Skeleton cards matching grid layout
|

## Checklist

- [x] Code follows project conventions
- [x] No `any` types introduced
- [x] Lint and typecheck pass

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8979-fix-show-skeleton-loading-state-in-asset-folder-view-30c6d73d365081fa9809f616204ed234)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Alexander Brown
2026-02-18 18:35:36 -08:00
committed by GitHub
parent 8099cce232
commit faede75bb4
3 changed files with 73 additions and 18 deletions

View File

@@ -79,8 +79,21 @@
<Divider v-else type="dashed" class="my-2" />
</template>
<template #body>
<div v-if="showLoadingState">
<ProgressSpinner class="absolute left-1/2 w-[50px] -translate-x-1/2" />
<div
v-if="showLoadingState"
class="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-2 px-2"
>
<div
v-for="n in skeletonCount"
:key="`skeleton-${n}`"
class="flex flex-col gap-2 p-2"
>
<Skeleton class="aspect-square w-full rounded-lg" />
<div class="flex flex-col gap-1">
<Skeleton class="h-4 w-3/4" />
<Skeleton class="h-3 w-1/2" />
</div>
</div>
</div>
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
@@ -206,6 +219,7 @@
<script setup lang="ts">
import {
useAsyncState,
useDebounceFn,
useElementHover,
useResizeObserver,
@@ -213,7 +227,6 @@ import {
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -225,6 +238,7 @@ const Load3dViewerContent = () =>
import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridView.vue'
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
@@ -237,6 +251,7 @@ import { useAssetSelection } from '@/platform/assets/composables/useAssetSelecti
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
@@ -260,6 +275,7 @@ const settingStore = useSettingStore()
const activeTab = ref<'input' | 'output'>('output')
const folderPromptId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
const isInFolderView = computed(() => folderPromptId.value !== null)
const viewMode = useStorage<'list' | 'grid'>(
'Comfy.Assets.Sidebar.ViewMode',
@@ -376,7 +392,24 @@ const mediaAssets = computed(() => currentAssets.value.media.value)
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
const folderAssets = ref<AssetItem[]>([])
const DEFAULT_SKELETON_COUNT = 6
const skeletonCount = computed(() =>
expectedFolderCount.value > 0
? expectedFolderCount.value
: DEFAULT_SKELETON_COUNT
)
const {
state: folderAssets,
isLoading: folderLoading,
error: folderError,
execute: loadFolderAssets
} = useAsyncState(
(metadata: OutputAssetMetadata, options: { createdAt?: string } = {}) =>
resolveOutputAssetItems(metadata, options),
[] as AssetItem[],
{ immediate: false, resetOnExecute: true }
)
// Base assets before search filtering
const baseAssets = computed(() => {
@@ -414,9 +447,13 @@ const isBulkMode = computed(
() => hasSelection.value && selectedAssets.value.length > 1
)
const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(
() =>
loading.value &&
(loading.value || isFolderLoading.value) &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -424,6 +461,7 @@ const showLoadingState = computed(
const showEmptyState = computed(
() =>
!loading.value &&
!isFolderLoading.value &&
displayAssets.value.length === 0 &&
activeJobsCount.value === 0
)
@@ -599,27 +637,25 @@ const enterFolderView = async (asset: AssetItem) => {
folderPromptId.value = promptId
folderExecutionTime.value = executionTimeInSeconds
expectedFolderCount.value = metadata.outputCount ?? 0
let folderItems: AssetItem[] = []
try {
folderItems = await resolveOutputAssetItems(metadata, {
createdAt: asset.created_at
await loadFolderAssets(0, metadata, { createdAt: asset.created_at })
if (folderError.value) {
toast.add({
severity: 'error',
summary: t('sideToolbar.folderView.errorSummary'),
detail: t('sideToolbar.folderView.errorDetail'),
life: 5000
})
} catch (error) {
console.error('Failed to resolve outputs for folder view:', error)
exitFolderView()
}
if (folderItems.length === 0) {
console.warn('No outputs available for folder view')
return
}
folderAssets.value = folderItems
}
const exitFolderView = () => {
folderPromptId.value = null
folderExecutionTime.value = undefined
expectedFolderCount.value = 0
folderAssets.value = []
searchQuery.value = ''
}

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { class: className } = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div
:class="cn('animate-pulse rounded-md bg-secondary-background', className)"
/>
</template>

View File

@@ -743,6 +743,10 @@
"filterText": "Text"
},
"backToAssets": "Back to all assets",
"folderView": {
"errorSummary": "Failed to load outputs",
"errorDetail": "Could not retrieve outputs for this job. Please try again."
},
"searchAssets": "Search Assets",
"labels": {
"queue": "Queue",