Compare commits

...

7 Commits

Author SHA1 Message Date
Simon Pinfold
bba433016c fix(assets): hide group count badge on expanded child rows in list view
Group children resolve to the same group as their representative, so
the count override gave every child row its own layers badge. Child
rows never show a stack count.
2026-06-11 15:40:13 +12:00
Simon Pinfold
37807280a4 feat(assets): job grouping and preview badge in sidebar list view
List view rows now show the PREV. badge for temp assets and, with
group-by-job on, a group-count stack that expands the in-memory group
members inline. useOutputStacks gains getJobId/liveChildren overrides
so grouped expansion skips history resolution; history-path behavior
is unchanged when the overrides return undefined.
2026-06-11 15:14:17 +12:00
Simon Pinfold
3121149008 fix(assets): exclude preview assets from display and group counts unless toggled
Group-by-job badge counts derive from the displayed list, so filter
temp-tagged assets out client-side whenever 'Show preview assets' is
off. The streams already fetch temp only when toggled, but this also
covers assets tagged both output and temp and temp assets still loaded
after the toggle flips off. The previews/sort refetch now also fires
while in folder view so the main list is fresh on exit.
2026-06-11 13:42:26 +12:00
Simon Pinfold
a59c496778 feat(assets): refresh assets-API sidebar when jobs complete
The websocket status/execution_success handlers refreshed only the
history path. Route them through a source-aware helper: linear mode
keeps consuming history, and the open sidebar refreshes whichever data
source is active. The active-source predicate moves into the assets
store (assetApiSourceActive) so GraphView and the sidebar share it.
2026-06-11 13:14:55 +12:00
Simon Pinfold
4667b7900e feat(assets): switch media sidebar to assets API behind UseAssetAPI setting
The sidebar reads Comfy.Assets.UseAssetAPI directly (isAssetAPIEnabled
hard-fails outside cloud) and falls back to the history path for the
session when the API returns 503/404. On the API path: All/Generated/
Uploaded tabs, server-side newest/oldest/alphabetical sorts, Show
preview assets and Group assets by job toggles with drill-in served
from the in-memory group. Context-menu actions resolve job ids from
asset.job_id so copy/export/open-workflow and zip download work with
API-shaped records. With the setting off, behavior is unchanged.

Asset tag constants move to a dependency-free module so components can
import them without the service's i18n import chain (re-exported from
assetService for existing importers).
2026-06-10 20:01:58 +12:00
Simon Pinfold
82c758fd0d feat(assets): PREV. badge, Keep preview action, job grouping composable
Temp-tagged assets show a PREV. pill in the card info row and gain a
'Keep preview' context-menu action that adds the output tag before
removing temp (removing temp alone orphans the asset out of every tab
query) with optimistic local update. useJobGrouping buckets loaded
assets by job_id with trailing-group hold-back while more pages exist.
2026-06-10 18:37:59 +12:00
Simon Pinfold
dc09506d06 feat(assets): add union-stream assets API fetch path to assets store
Adds job_id to the asset schema, sort/order passthrough and a generic
getAssetsPage to the asset service, and a per-tag stream union in the
assets store (dedupe by id, merge by active sort key, frontier hold-back
for correct infinite scroll). 503/404 on first fetch sets a session-scoped
assetApiUnavailable flag so the sidebar can fall back to the history path.
2026-06-10 18:31:10 +12:00
24 changed files with 1512 additions and 79 deletions

View File

@@ -37,7 +37,12 @@
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:badge-label="
item.asset.tags?.includes(TEMP_TAG)
? t('mediaAsset.previewBadge')
: undefined
"
:stack-count="item.isChild ? undefined : stackCountFor(item.asset)"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@@ -74,6 +79,7 @@ import VirtualGrid from '@/components/common/VirtualGrid.vue'
import Button from '@/components/ui/button/Button.vue'
import AssetsListItem from '@/platform/assets/components/AssetsListItem.vue'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { TEMP_TAG } from '@/platform/assets/constants/assetTags'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getAssetDisplayName } from '@/platform/assets/utils/assetMetadataUtils'
@@ -92,13 +98,16 @@ const {
selectableAssets,
isSelected,
isStackExpanded,
toggleStack
toggleStack,
getStackCount
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
isSelected: (assetId: string) => boolean
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
/** Override the stack count (e.g. job-group size); undefined falls back to history metadata. */
getStackCount?: (asset: AssetItem) => number | undefined
}>()
const assetsStore = useAssetsStore()
@@ -158,7 +167,10 @@ function getAssetSecondaryText(asset: AssetItem): string {
return ''
}
function getStackCount(asset: AssetItem): number | undefined {
function stackCountFor(asset: AssetItem): number | undefined {
const override = getStackCount?.(asset)
if (override !== undefined) return override
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (typeof metadata?.outputCount === 'number') {
return metadata.outputCount

View File

@@ -39,8 +39,15 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
v-model:show-preview-assets="showPreviewAssets"
v-model:group-by-job="groupByJob"
bottom-divider
:show-generation-time-sort="activeTab === 'output'"
:show-generation-time-sort="
!useAssetApiSource && activeTab === 'output'
"
:show-alphabetical-sort="useAssetApiSource"
:show-asset-toggles="useAssetApiSource && activeTab !== 'input'"
:show-sort-options="useAssetApiSource || undefined"
/>
<!-- Tab list -->
<div
@@ -48,8 +55,19 @@
class="border-b border-comfy-input p-2 2xl:px-4"
>
<TabList v-model="activeTab">
<Tab v-if="useAssetApiSource" value="all">
{{ $t('sideToolbar.labels.all') }}
</Tab>
<Tab value="output">{{ $t('sideToolbar.labels.generated') }}</Tab>
<Tab value="input">{{ $t('sideToolbar.labels.imported') }}</Tab>
<Tab value="input">
{{
$t(
useAssetApiSource
? 'sideToolbar.labels.uploaded'
: 'sideToolbar.labels.imported'
)
}}
</Tab>
</TabList>
</div>
</template>
@@ -73,13 +91,7 @@
<div v-else-if="showEmptyState">
<NoResultsPlaceholder
icon="pi pi-info-circle"
:title="
$t(
activeTab === 'input'
? 'sideToolbar.noImportedFiles'
: 'sideToolbar.noGeneratedFiles'
)
"
:title="$t(emptyStateTitleKey)"
:message="$t('sideToolbar.noFilesFoundMessage')"
/>
</div>
@@ -95,6 +107,7 @@
:selectable-assets="listViewSelectableAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:get-stack-count="listViewStackCount"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -105,7 +118,7 @@
:assets="displayAssets"
:is-selected="isSelected"
:show-output-count="shouldShowOutputCount"
:get-output-count="getOutputCount"
:get-output-count="getDisplayOutputCount"
@select-asset="handleAssetSelect"
@context-menu="handleAssetContextMenu"
@approach-end="handleApproachEnd"
@@ -239,9 +252,19 @@ import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBa
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useJobGrouping } from '@/platform/assets/composables/useJobGrouping'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import {
INPUT_TAG,
OUTPUT_TAG,
TEMP_TAG
} from '@/platform/assets/constants/assetTags'
import type {
AssetSortField,
AssetSortOrder
} from '@/platform/assets/services/assetService'
import type { OutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
@@ -250,6 +273,7 @@ import { getAssetUrl } from '@/platform/assets/utils/assetUrlUtil'
import type { MediaKind } from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useAssetsStore } from '@/stores/assetsStore'
import { useDialogStore } from '@/stores/dialogStore'
import { ResultItemImpl } from '@/stores/queueStore'
import {
@@ -267,7 +291,7 @@ const { t } = useI18n()
const emit = defineEmits<{ assetSelected: [asset: AssetItem] }>()
const activeTab = ref<'input' | 'output'>('output')
const activeTab = ref<'all' | 'input' | 'output'>('output')
const folderJobId = ref<string | null>(null)
const folderExecutionTime = ref<number | undefined>(undefined)
const expectedFolderCount = ref(0)
@@ -278,6 +302,21 @@ const viewMode = useStorage<'list' | 'grid'>(
)
const isListView = computed(() => viewMode.value === 'list')
const assetsStore = useAssetsStore()
/**
* Data-source switch for the sidebar: the assets API path is used when the
* setting is on and the API hasn't been detected as unavailable this session
* (503/404 on first fetch falls back to the history path).
*/
const useAssetApiSource = computed(() => assetsStore.assetApiSourceActive)
const showPreviewAssets = useStorage(
'Comfy.Assets.Sidebar.ShowPreviewAssets',
false
)
const groupByJob = useStorage('Comfy.Assets.Sidebar.GroupByJob', false)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -288,21 +327,29 @@ const shouldShowDeleteButton = computed(() => {
return true
})
const contextMenuAssetType = computed(() =>
contextMenuAsset.value ? getAssetType(contextMenuAsset.value.tags) : 'input'
)
const contextMenuAssetType = computed(() => {
if (!contextMenuAsset.value) return 'input'
// Preview (temp) assets behave like outputs everywhere except the badge.
if (contextMenuAsset.value.tags?.includes(TEMP_TAG)) return 'output'
return getAssetType(contextMenuAsset.value.tags)
})
const contextMenuFileKind = computed<MediaKind>(() =>
getMediaTypeFromFilename(contextMenuAsset.value?.name ?? '')
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
if (isInFolderView.value) return false
if (useAssetApiSource.value) {
return groupingEnabled.value && getGroupCount(item) > 1
}
if (activeTab.value !== 'output') return false
return getOutputCount(item) > 1
}
const getDisplayOutputCount = (item: AssetItem): number =>
groupingEnabled.value ? getGroupCount(item) : getOutputCount(item)
const formattedExecutionTime = computed(() => {
if (!folderExecutionTime.value) return ''
return formatDuration(folderExecutionTime.value * 1000)
@@ -313,6 +360,46 @@ const toast = useToast()
const inputAssets = useAssetsApi('input')
const outputAssets = useAssetsApi('output')
/** Tag streams backing each tab on the assets-API path. */
const apiTags = computed(() => {
const tags: string[] = []
if (activeTab.value !== 'input') {
tags.push(OUTPUT_TAG)
if (showPreviewAssets.value) tags.push(TEMP_TAG)
}
if (activeTab.value !== 'output') tags.push(INPUT_TAG)
return tags
})
const apiSort = computed<{ sort: AssetSortField; order: AssetSortOrder }>(
() => {
switch (sortBy.value) {
case 'oldest':
return { sort: 'created_at', order: 'asc' }
case 'name-asc':
return { sort: 'name', order: 'asc' }
case 'name-desc':
return { sort: 'name', order: 'desc' }
default:
return { sort: 'created_at', order: 'desc' }
}
}
)
const apiAssetsProvider = {
media: computed(() => assetsStore.apiAssets),
loading: computed(() => assetsStore.apiLoading),
error: computed(() => assetsStore.apiError),
fetchMediaList: async () => {
await assetsStore.fetchApiAssets({ tags: apiTags.value, ...apiSort.value })
return assetsStore.apiAssets
},
refresh: () => apiAssetsProvider.fetchMediaList(),
loadMore: () => assetsStore.loadMoreApiAssets(),
hasMore: computed(() => assetsStore.apiHasMore),
isLoadingMore: computed(() => assetsStore.apiIsLoadingMore)
}
// Asset selection
const {
isSelected,
@@ -362,12 +449,29 @@ const totalOutputCount = computed(() => {
return getTotalOutputCount(selectedAssets.value)
})
const currentAssets = computed(() =>
activeTab.value === 'input' ? inputAssets : outputAssets
)
const currentAssets = computed(() => {
if (useAssetApiSource.value) return apiAssetsProvider
return activeTab.value === 'input' ? inputAssets : outputAssets
})
const emptyStateTitleKey = computed(() => {
if (activeTab.value === 'input') return 'sideToolbar.noImportedFiles'
if (activeTab.value === 'all') return 'sideToolbar.noFilesFound'
return 'sideToolbar.noGeneratedFiles'
})
const loading = computed(() => currentAssets.value.loading.value)
const error = computed(() => currentAssets.value.error.value)
const mediaAssets = computed(() => currentAssets.value.media.value)
/**
* Loaded assets with preview (temp) assets removed unless the toggle is on.
* The streams already fetch temp only when toggled, but this guard keeps
* display and group counts correct for assets carrying both output and temp
* tags, and for loaded temp assets after the toggle is switched off.
*/
const mediaAssets = computed(() => {
const assets = currentAssets.value.media.value
if (!useAssetApiSource.value || showPreviewAssets.value) return assets
return assets.filter((asset) => !asset.tags?.includes(TEMP_TAG))
})
const galleryActiveIndex = ref(-1)
const currentGalleryAssetId = ref<string | null>(null)
@@ -403,8 +507,28 @@ const baseAssets = computed(() => {
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const isTimeSort = computed(
() => sortBy.value === 'newest' || sortBy.value === 'oldest'
)
const groupingEnabled = computed(
() =>
useAssetApiSource.value &&
groupByJob.value &&
!isInFolderView.value &&
activeTab.value !== 'input'
)
const { groupedAssets, getGroup, getGroupCount } = useJobGrouping({
assets: filteredAssets,
enabled: groupingEnabled,
holdBackTrailing: computed(
() => isTimeSort.value && assetsStore.apiHasMore && !searchQuery.value
)
})
const displayAssets = computed(() => {
return filteredAssets.value
return groupingEnabled.value ? groupedAssets.value : filteredAssets.value
})
const {
@@ -413,9 +537,22 @@ const {
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: computed(() => displayAssets.value)
assets: computed(() => displayAssets.value),
// With job grouping on, stacks expand the in-memory group instead of
// resolving history outputs.
getJobId: (asset) =>
groupingEnabled.value ? (getGroup(asset)?.jobId ?? null) : undefined,
liveChildren: (asset) => {
if (!groupingEnabled.value) return undefined
const group = getGroup(asset)
if (!group) return []
return group.assets.filter((member) => member.id !== asset.id)
}
})
const listViewStackCount = (asset: AssetItem): number | undefined =>
groupingEnabled.value ? getGroupCount(asset) : undefined
const visibleAssets = computed(() => {
if (!isListView.value) return displayAssets.value
return listViewSelectableAssets.value
@@ -495,8 +632,22 @@ const refreshAssets = async () => {
}
watch(
activeTab,
[activeTab, useAssetApiSource],
() => {
if (!useAssetApiSource.value) {
// Leaving the API path (setting off or runtime fallback): drop
// API-only state so the history path behaves exactly as before.
if (activeTab.value === 'all') {
activeTab.value = 'output'
return
}
if (sortBy.value === 'name-asc' || sortBy.value === 'name-desc') {
sortBy.value = 'newest'
}
} else if (sortBy.value === 'longest' || sortBy.value === 'fastest') {
// Generation-time sorts don't exist on asset records.
sortBy.value = 'newest'
}
clearSelection()
// Clear search when switching tabs
searchQuery.value = ''
@@ -506,6 +657,15 @@ watch(
{ immediate: true }
)
// Server-side parameters of the API path: refetch when they change, even
// while in folder view, so the main list is fresh on exit. (On the history
// path sorting stays client-side, as before.)
watch([showPreviewAssets, apiSort], () => {
if (useAssetApiSource.value) {
void refreshAssets()
}
})
function handleAssetSelect(asset: AssetItem, assets?: AssetItem[]) {
const assetList = assets ?? visibleAssets.value
const index = assetList.findIndex((a) => a.id === asset.id)
@@ -603,6 +763,16 @@ const handleZoomClick = (asset: AssetItem) => {
}
const enterFolderView = async (asset: AssetItem) => {
if (groupingEnabled.value) {
const group = getGroup(asset)
if (!group?.jobId) return
folderJobId.value = group.jobId
folderExecutionTime.value = undefined
expectedFolderCount.value = group.assets.length
folderAssets.value = group.assets
return
}
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (!metadata) {
console.warn('Invalid output asset metadata')
@@ -679,9 +849,18 @@ const copyJobId = async () => {
}
const handleApproachEnd = useDebounceFn(async () => {
if (isInFolderView.value) return
if (useAssetApiSource.value) {
if (
apiAssetsProvider.hasMore.value &&
!apiAssetsProvider.isLoadingMore.value
) {
await apiAssetsProvider.loadMore()
}
return
}
if (
activeTab.value === 'output' &&
!isInFolderView.value &&
outputAssets.hasMore.value &&
!outputAssets.isLoadingMore.value
) {

View File

@@ -854,7 +854,11 @@
"filterAudio": "Audio",
"filter3D": "3D",
"filterText": "Text",
"viewSettings": "View settings"
"viewSettings": "View settings",
"sortNameAsc": "Alphabetical (A→Z)",
"sortNameDesc": "Alphabetical (Z→A)",
"showPreviewAssets": "Show preview assets",
"groupByJob": "Group assets by job"
},
"backToAssets": "Back to all assets",
"folderView": {
@@ -872,7 +876,9 @@
"console": "Console",
"menu": "Menu",
"imported": "Imported",
"generated": "Generated"
"generated": "Generated",
"all": "All",
"uploaded": "Uploaded"
},
"noFilesFound": "No files found",
"noImportedFiles": "No imported files found",
@@ -3210,7 +3216,8 @@
"openWorkflow": "Open as workflow in new tab",
"exportWorkflow": "Export workflow",
"copyJobId": "Copy job ID",
"delete": "Delete"
"delete": "Delete",
"keepPreview": "Keep preview"
},
"jobIdToast": {
"jobIdCopied": "Job ID copied to clipboard",
@@ -3253,7 +3260,11 @@
"noWorkflowDataFound": "No workflow data found in this asset",
"workflowOpenedInNewTab": "Workflow opened in new tab",
"failedToExportWorkflow": "Failed to export workflow",
"workflowExportedSuccessfully": "Workflow exported successfully"
"workflowExportedSuccessfully": "Workflow exported successfully",
"previewBadge": "PREV.",
"previewBadgeTooltip": "Temporary outputs from workflow runs. Right click to save this preview.",
"previewKept": "Preview saved to Media Assets",
"failedToKeepPreview": "Failed to keep preview"
},
"actionbar": {
"dockToTop": "Dock to top",

View File

@@ -97,6 +97,13 @@
</div>
</div>
<span
v-if="badgeLabel"
class="relative z-1 flex h-3.5 shrink-0 items-center rounded-full bg-base-foreground px-1 text-[9px] font-semibold text-base-background uppercase"
>
{{ badgeLabel }}
</span>
<div
v-if="$slots.actions"
class="relative z-1 flex shrink-0 items-center gap-2"
@@ -156,6 +163,7 @@ const {
isVideoPreview = false,
primaryText,
secondaryText,
badgeLabel,
stackCount,
stackIndicatorLabel,
stackExpanded = false,
@@ -171,6 +179,7 @@ const {
isVideoPreview?: boolean
primaryText?: string
secondaryText?: string
badgeLabel?: string
stackCount?: number
stackIndicatorLabel?: string
stackExpanded?: boolean

View File

@@ -116,9 +116,20 @@
</div>
</div>
<!-- Right side: Output count -->
<div v-if="showOutputCount" class="shrink-0">
<!-- Right side: Preview badge and output count -->
<div
v-if="isTempAsset || showOutputCount"
class="flex shrink-0 items-center gap-1"
>
<span
v-if="isTempAsset"
v-tooltip.top="$t('mediaAsset.previewBadgeTooltip')"
class="flex h-3.5 items-center rounded-full bg-base-foreground px-1 text-[9px] font-semibold text-base-background uppercase"
>
{{ $t('mediaAsset.previewBadge') }}
</span>
<Button
v-if="showOutputCount"
v-tooltip.top.pt:pointer-events-none="
$t('mediaAsset.actions.seeMoreOutputs')
"
@@ -154,6 +165,7 @@ import {
isPreviewableMediaType
} from '@/utils/formatUtil'
import { TEMP_TAG } from '../constants/assetTags'
import { getAssetType } from '../composables/media/assetMappers'
import { getAssetUrl } from '../utils/assetUrlUtil'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
@@ -219,6 +231,8 @@ const assetType = computed(() => {
return getAssetType(asset?.tags)
})
const isTempAsset = computed(() => asset?.tags?.includes(TEMP_TAG) ?? false)
// Determine file type from extension
const fileKind = computed((): MediaKind => {
return getMediaTypeFromFilename(asset?.name || '')

View File

@@ -43,6 +43,7 @@ import { isPreviewableMediaType } from '@/utils/formatUtil'
import { detectNodeTypeFromFilename } from '@/utils/loaderNodeUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { TEMP_TAG } from '../constants/assetTags'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { AssetContext, MediaKind } from '../schemas/mediaAssetSchema'
@@ -213,6 +214,15 @@ const contextMenuItems = computed<MenuItem[]>(() => {
})
}
// Keep preview (temp assets only): promote to permanent output asset
if (asset.tags?.includes(TEMP_TAG)) {
items.push({
label: t('mediaAsset.actions.keepPreview'),
icon: 'icon-[lucide--bookmark]',
command: () => actions.keepPreview(asset)
})
}
// Download
items.push({
label: t('mediaAsset.actions.download'),

View File

@@ -27,8 +27,12 @@
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
:show-sort-options="isCloud"
v-model:show-preview-assets="showPreviewAssets"
v-model:group-by-job="groupByJob"
:show-sort-options="showSortOptions ?? isCloud"
:show-generation-time-sort
:show-alphabetical-sort="showAlphabeticalSort"
:show-asset-toggles="showAssetToggles"
/>
</template>
</MediaAssetSettingsButton>
@@ -47,9 +51,18 @@ import MediaAssetSettingsButton from './MediaAssetSettingsButton.vue'
import MediaAssetSettingsMenu from './MediaAssetSettingsMenu.vue'
import type { SortBy } from './MediaAssetSettingsMenu.vue'
const { showGenerationTimeSort = false, bottomDivider = false } = defineProps<{
const {
showGenerationTimeSort = false,
showAlphabeticalSort = false,
showAssetToggles = false,
showSortOptions = undefined,
bottomDivider = false
} = defineProps<{
searchQuery: string
showGenerationTimeSort?: boolean
showAlphabeticalSort?: boolean
showAssetToggles?: boolean
showSortOptions?: boolean
mediaTypeFilters: string[]
bottomDivider?: boolean
}>()
@@ -61,6 +74,10 @@ const emit = defineEmits<{
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const showPreviewAssets = defineModel<boolean>('showPreviewAssets', {
default: false
})
const groupByJob = defineModel<boolean>('groupByJob', { default: false })
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')

View File

@@ -57,6 +57,32 @@
/>
</Button>
<template v-if="showAlphabeticalSort">
<Button
variant="textonly"
class="w-full"
@click="handleSortChange('name-asc')"
>
<span>{{ $t('sideToolbar.mediaAssets.sortNameAsc') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'name-asc' && 'opacity-0'"
/>
</Button>
<Button
variant="textonly"
class="w-full"
@click="handleSortChange('name-desc')"
>
<span>{{ $t('sideToolbar.mediaAssets.sortNameDesc') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="sortBy !== 'name-desc' && 'opacity-0'"
/>
</Button>
</template>
<template v-if="showGenerationTimeSort">
<Button
variant="textonly"
@@ -83,22 +109,70 @@
</Button>
</template>
</template>
<template v-if="showAssetToggles">
<div class="my-1 w-full border-b border-border-subtle" />
<Button
variant="textonly"
class="w-full"
role="menuitemcheckbox"
:aria-checked="groupByJob"
@click="groupByJob = !groupByJob"
>
<span>{{ $t('sideToolbar.mediaAssets.groupByJob') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="!groupByJob && 'opacity-0'"
/>
</Button>
<Button
variant="textonly"
class="w-full"
role="menuitemcheckbox"
:aria-checked="showPreviewAssets"
@click="showPreviewAssets = !showPreviewAssets"
>
<span>{{ $t('sideToolbar.mediaAssets.showPreviewAssets') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="!showPreviewAssets && 'opacity-0'"
/>
</Button>
</template>
</div>
</template>
<script setup lang="ts">
import Button from '@/components/ui/button/Button.vue'
export type SortBy = 'newest' | 'oldest' | 'longest' | 'fastest'
export type SortBy =
| 'newest'
| 'oldest'
| 'longest'
| 'fastest'
| 'name-asc'
| 'name-desc'
const { showSortOptions = false, showGenerationTimeSort = false } =
defineProps<{
showSortOptions?: boolean
showGenerationTimeSort?: boolean
}>()
const {
showSortOptions = false,
showGenerationTimeSort = false,
showAlphabeticalSort = false,
showAssetToggles = false
} = defineProps<{
showSortOptions?: boolean
showGenerationTimeSort?: boolean
showAlphabeticalSort?: boolean
showAssetToggles?: boolean
}>()
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const showPreviewAssets = defineModel<boolean>('showPreviewAssets', {
default: false
})
const groupByJob = defineModel<boolean>('groupByJob', { default: false })
function handleViewModeChange(value: 'list' | 'grid') {
viewMode.value = value

View File

@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { useJobGrouping } from '@/platform/assets/composables/useJobGrouping'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
function asset(id: string, jobId?: string): AssetItem {
return {
id,
name: `${id}.png`,
tags: ['output'],
job_id: jobId
}
}
function setup(
items: AssetItem[],
{ enabled = true, holdBackTrailing = false } = {}
) {
const assets = ref(items)
const grouping = useJobGrouping({
assets,
enabled: ref(enabled),
holdBackTrailing: ref(holdBackTrailing)
})
return { assets, ...grouping }
}
describe('useJobGrouping', () => {
it('passes assets through unchanged when disabled', () => {
const items = [asset('a', 'job1'), asset('b', 'job1')]
const { groupedAssets } = setup(items, { enabled: false })
expect(groupedAssets.value).toEqual(items)
})
it('buckets assets by job_id with first occurrence as representative', () => {
const { groupedAssets, getGroupCount } = setup([
asset('a1', 'job1'),
asset('b1', 'job2'),
asset('a2', 'job1'),
asset('a3', 'job1')
])
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1', 'b1'])
expect(getGroupCount(asset('a1', 'job1'))).toBe(3)
expect(getGroupCount(asset('b1', 'job2'))).toBe(1)
})
it('treats assets without job_id as singleton groups', () => {
const { groupedAssets, getGroupCount } = setup([
asset('a', 'job1'),
asset('x'),
asset('y')
])
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a', 'x', 'y'])
expect(getGroupCount(asset('x'))).toBe(1)
})
it('exposes group members for drill-in', () => {
const { getGroup } = setup([
asset('a1', 'job1'),
asset('b1', 'job2'),
asset('a2', 'job1')
])
const group = getGroup(asset('a2', 'job1'))
expect(group?.jobId).toBe('job1')
expect(group?.assets.map((a) => a.id)).toEqual(['a1', 'a2'])
})
it('holds back the group containing the trailing asset', () => {
const { groupedAssets } = setup(
[asset('a1', 'job1'), asset('b1', 'job2'), asset('b2', 'job2')],
{ holdBackTrailing: true }
)
// job2 owns the last loaded asset; the next page may add more members.
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1'])
})
it('keeps the trailing group when it is the only group', () => {
const { groupedAssets } = setup(
[asset('a1', 'job1'), asset('a2', 'job1')],
{ holdBackTrailing: true }
)
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1'])
})
it('reacts to asset list changes', () => {
const { assets, groupedAssets } = setup([asset('a1', 'job1')])
expect(groupedAssets.value).toHaveLength(1)
assets.value = [...assets.value, asset('a2', 'job1'), asset('c1', 'job3')]
expect(groupedAssets.value.map((a) => a.id)).toEqual(['a1', 'c1'])
})
})

View File

@@ -0,0 +1,90 @@
import { computed } from 'vue'
import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
export interface JobGroup {
/** Stable key: the job id, or a per-asset key for assets without one. */
key: string
jobId: string | null
/** First group member in display order; shown as the group's card. */
representative: AssetItem
assets: AssetItem[]
}
/**
* Client-side group-by-job over the loaded (already filtered and sorted)
* asset list. Assets without a job_id form singleton groups. Group order
* and each group's representative follow first occurrence in display order,
* so grouping works under any active sort.
*
* While more pages exist under a time sort, the group containing the last
* loaded asset may still be split across the page boundary; `holdBackTrailing`
* hides it until the next page lands (or streams exhaust) to avoid
* undercounting.
*/
export function useJobGrouping(options: {
assets: Ref<AssetItem[]>
enabled: Ref<boolean>
holdBackTrailing: Ref<boolean>
}) {
const { assets, enabled, holdBackTrailing } = options
const groups = computed<JobGroup[]>(() => {
if (!enabled.value) return []
const byKey = new Map<string, JobGroup>()
for (const asset of assets.value) {
const key = asset.job_id ?? `asset:${asset.id}`
const group = byKey.get(key)
if (group) {
group.assets.push(asset)
} else {
byKey.set(key, {
key,
jobId: asset.job_id ?? null,
representative: asset,
assets: [asset]
})
}
}
const result = [...byKey.values()]
if (holdBackTrailing.value && result.length > 1) {
const lastAsset = assets.value[assets.value.length - 1]
const trailingKey = lastAsset.job_id ?? `asset:${lastAsset.id}`
return result.filter((group) => group.key !== trailingKey)
}
return result
})
const groupsByAssetId = computed(() => {
const map = new Map<string, JobGroup>()
for (const group of groups.value) {
for (const asset of group.assets) {
map.set(asset.id, group)
}
}
return map
})
/** Representatives in display order; the grid renders one card per group. */
const groupedAssets = computed<AssetItem[]>(() => {
if (!enabled.value) return assets.value
return groups.value.map((group) => group.representative)
})
function getGroup(asset: AssetItem): JobGroup | undefined {
return groupsByAssetId.value.get(asset.id)
}
function getGroupCount(asset: AssetItem): number {
return getGroup(asset)?.assets.length ?? 1
}
return {
groupedAssets,
getGroup,
getGroupCount
}
}

View File

@@ -58,13 +58,15 @@ const mockSetAssetDeleting = vi.hoisted(() => vi.fn())
const mockUpdateHistory = vi.hoisted(() => vi.fn())
const mockUpdateInputs = vi.hoisted(() => vi.fn())
const mockHasCategory = vi.hoisted(() => vi.fn())
const mockPatchApiAsset = vi.hoisted(() => vi.fn())
vi.mock('@/stores/assetsStore', () => ({
useAssetsStore: () => ({
setAssetDeleting: mockSetAssetDeleting,
updateHistory: mockUpdateHistory,
updateInputs: mockUpdateInputs,
invalidateModelsForCategory: mockInvalidateModelsForCategory,
hasCategory: mockHasCategory
hasCategory: mockHasCategory,
patchApiAsset: mockPatchApiAsset
})
}))
@@ -149,11 +151,17 @@ const mockDeleteAsset = vi.hoisted(() => vi.fn())
const mockCreateAssetExport = vi.hoisted(() =>
vi.fn().mockResolvedValue({ task_id: 'test-task-id', status: 'pending' })
)
const mockAddAssetTags = vi.hoisted(() => vi.fn())
const mockRemoveAssetTags = vi.hoisted(() => vi.fn())
vi.mock('../services/assetService', () => ({
assetService: {
deleteAsset: mockDeleteAsset,
createAssetExport: mockCreateAssetExport
}
createAssetExport: mockCreateAssetExport,
addAssetTags: mockAddAssetTags,
removeAssetTags: mockRemoveAssetTags
},
OUTPUT_TAG: 'output',
TEMP_TAG: 'temp'
}))
const mockTrackExport = vi.hoisted(() => vi.fn())
@@ -1066,4 +1074,47 @@ describe('useMediaAssetActions', () => {
expect(mockCaptureCanvasState).not.toHaveBeenCalled()
})
})
describe('keepPreview', () => {
it('adds the output tag before removing temp', async () => {
mockAddAssetTags.mockResolvedValue({ total_tags: ['temp', 'output'] })
mockRemoveAssetTags.mockResolvedValue({ total_tags: ['output'] })
const actions = useMediaAssetActions()
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
await actions.keepPreview(asset)
expect(mockAddAssetTags).toHaveBeenCalledWith('temp-1', ['output'])
expect(mockRemoveAssetTags).toHaveBeenCalledWith('temp-1', ['temp'])
expect(mockAddAssetTags.mock.invocationCallOrder[0]).toBeLessThan(
mockRemoveAssetTags.mock.invocationCallOrder[0]
)
})
it('optimistically replaces temp with output in the local cache', async () => {
mockAddAssetTags.mockResolvedValue({ total_tags: ['temp', 'output'] })
mockRemoveAssetTags.mockResolvedValue({ total_tags: ['output'] })
const actions = useMediaAssetActions()
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
await actions.keepPreview(asset)
expect(mockPatchApiAsset).toHaveBeenCalledWith('temp-1', {
tags: ['output']
})
})
it('reverts the optimistic update when the API calls fail', async () => {
mockAddAssetTags.mockRejectedValue(new Error('boom'))
const actions = useMediaAssetActions()
const asset = createMockAsset({ id: 'temp-1', tags: ['temp'] })
await actions.keepPreview(asset)
expect(mockRemoveAssetTags).not.toHaveBeenCalled()
expect(mockPatchApiAsset).toHaveBeenLastCalledWith('temp-1', {
tags: ['temp']
})
})
})
})

View File

@@ -33,7 +33,7 @@ import { useAssetExportStore } from '@/stores/assetExportStore'
import type { AssetItem } from '../schemas/assetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import { assetService } from '../services/assetService'
import { OUTPUT_TAG, TEMP_TAG, assetService } from '../services/assetService'
const EXCLUDED_TAGS = new Set(['models', 'input', 'output'])
@@ -86,6 +86,12 @@ export function useMediaAssetActions() {
asset: AssetItem,
assetType: string
): Promise<void> => {
// Assets-API records (job_id is only set by /api/assets) are deleted
// directly; history-mapped output assets go through the history API.
if (asset.job_id != null) {
await assetService.deleteAsset(asset.id)
return
}
if (assetType === 'output') {
const jobId =
getOutputAssetMetadata(asset.user_metadata)?.jobId || asset.id
@@ -160,7 +166,7 @@ export function useMediaAssetActions() {
for (const asset of assets) {
if (getAssetType(asset) === 'output') {
const metadata = getOutputAssetMetadata(asset.user_metadata)
const jobId = metadata?.jobId || asset.id
const jobId = metadata?.jobId || asset.job_id || asset.id
if (!jobIds.includes(jobId)) {
jobIds.push(jobId)
}
@@ -176,12 +182,13 @@ export function useMediaAssetActions() {
fileCount += 1
}
if (metadata?.jobId && asset.name && metadata.outputCount == null) {
if (!jobAssetNameFilters[metadata.jobId]) {
jobAssetNameFilters[metadata.jobId] = []
const filterJobId = metadata?.jobId || asset.job_id
if (filterJobId && asset.name && metadata?.outputCount == null) {
if (!jobAssetNameFilters[filterJobId]) {
jobAssetNameFilters[filterJobId] = []
}
if (!jobAssetNameFilters[metadata.jobId].includes(asset.name)) {
jobAssetNameFilters[metadata.jobId].push(asset.name)
if (!jobAssetNameFilters[filterJobId].includes(asset.name)) {
jobAssetNameFilters[filterJobId].push(asset.name)
}
}
} else {
@@ -227,6 +234,40 @@ export function useMediaAssetActions() {
}
}
/**
* Promote a temp (preview) asset to a permanent output asset.
* Adds the output tag before removing temp so the asset is never
* orphaned out of every tab query, with an optimistic local update.
*/
const keepPreview = async (asset: AssetItem) => {
const assetsStore = useAssetsStore()
const originalTags = asset.tags ?? []
const keptTags = [
...originalTags.filter((tag) => tag !== TEMP_TAG),
...(originalTags.includes(OUTPUT_TAG) ? [] : [OUTPUT_TAG])
]
assetsStore.patchApiAsset(asset.id, { tags: keptTags })
try {
await assetService.addAssetTags(asset.id, [OUTPUT_TAG])
await assetService.removeAssetTags(asset.id, [TEMP_TAG])
toast.add({
severity: 'success',
summary: t('g.success'),
detail: t('mediaAsset.previewKept'),
life: 2000
})
} catch (error) {
console.error('Failed to keep preview asset:', error)
assetsStore.patchApiAsset(asset.id, { tags: originalTags })
toast.add({
severity: 'error',
summary: t('g.error'),
detail: t('mediaAsset.failedToKeepPreview')
})
}
}
const copyJobId = async (asset?: AssetItem) => {
const targetAsset = asset ?? mediaContext?.asset.value
if (!targetAsset) return
@@ -234,6 +275,7 @@ export function useMediaAssetActions() {
const metadata = getOutputAssetMetadata(targetAsset.user_metadata)
const jobId =
metadata?.jobId ||
targetAsset.job_id ||
(getAssetType(targetAsset) === 'output' ? targetAsset.id : undefined)
if (!jobId) {
@@ -787,6 +829,7 @@ export function useMediaAssetActions() {
return {
downloadAssets,
deleteAssets,
keepPreview,
copyJobId,
addWorkflow,
addMultipleToWorkflow,

View File

@@ -7,7 +7,13 @@ import type { Ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
type SortOption = 'newest' | 'oldest' | 'longest' | 'fastest'
type SortOption =
| 'newest'
| 'oldest'
| 'longest'
| 'fastest'
| 'name-asc'
| 'name-desc'
/**
* Get timestamp from asset (either create_time or created_at)
@@ -81,6 +87,14 @@ export function useMediaAssetFiltering(assets: Ref<AssetItem[]>) {
case 'fastest':
// Ascending order (fastest execution time first)
return sortByUtil(typeFiltered.value, [getAssetExecutionTime])
case 'name-asc':
return typeFiltered.value.toSorted((a, b) =>
a.name.localeCompare(b.name)
)
case 'name-desc':
return typeFiltered.value.toSorted((a, b) =>
b.name.localeCompare(a.name)
)
case 'newest':
default:
// Descending order (newest first) - negate for descending

View File

@@ -202,4 +202,68 @@ describe('useOutputStacks', () => {
child.id
])
})
describe('live children overrides (job grouping)', () => {
function createApiAsset(id: string, jobId: string | null): AssetItem {
return {
id,
name: `${id}.png`,
tags: ['output'],
created_at: '2025-01-01T00:00:00.000Z',
job_id: jobId
}
}
it('expands in-memory children without resolving history', async () => {
const rep = createApiAsset('rep', 'job-1')
const member = createApiAsset('member', 'job-1')
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([rep]),
getJobId: (asset) => asset.job_id ?? null,
liveChildren: (asset) => (asset.id === 'rep' ? [member] : [])
})
await toggleStack(rep)
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
'rep',
'member'
])
expect(assetItems.value[1].isChild).toBe(true)
})
it('does not stack assets whose override job id is null', async () => {
const single = createApiAsset('single', null)
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([single]),
getJobId: (asset) => asset.job_id ?? null,
liveChildren: () => []
})
await toggleStack(single)
expect(assetItems.value).toHaveLength(1)
})
it('falls back to history resolution when overrides return undefined', async () => {
const parent = createAsset({ id: 'parent' })
const child = createAsset({ id: 'child' })
mocks.resolveOutputAssetItems.mockResolvedValue([child])
const { assetItems, toggleStack } = useOutputStacks({
assets: ref([parent]),
getJobId: () => undefined,
liveChildren: () => undefined
})
await toggleStack(parent)
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(assetItems.value.map((item) => item.asset.id)).toEqual([
'parent',
'child'
])
})
})
})

View File

@@ -16,9 +16,24 @@ export type OutputStackListItem = {
type UseOutputStacksOptions = {
assets: Ref<AssetItem[]>
/**
* Override the job id used for stack identity. Return `undefined` to fall
* back to the default (history-shaped user_metadata), `null` for no stack.
*/
getJobId?: (asset: AssetItem) => string | null | undefined
/**
* Provide stack children synchronously from memory (e.g. job grouping on
* the assets-API path). Return `undefined` to fall back to the default
* async resolution from job history.
*/
liveChildren?: (asset: AssetItem) => AssetItem[] | undefined
}
export function useOutputStacks({ assets }: UseOutputStacksOptions) {
export function useOutputStacks({
assets,
getJobId,
liveChildren
}: UseOutputStacksOptions) {
const expandedStackJobIds = ref<Set<string>>(new Set())
const stackChildrenByJobId = ref<Record<string, AssetItem[]>>({})
const loadingStackJobIds = ref<Set<string>>(new Set())
@@ -37,7 +52,8 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
continue
}
const children = stackChildrenByJobId.value[jobId] ?? []
const children =
liveChildren?.(asset) ?? stackChildrenByJobId.value[jobId] ?? []
for (const child of children) {
items.push({
key: `asset-${child.id}`,
@@ -55,6 +71,8 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
)
function getStackJobId(asset: AssetItem): string | null {
const override = getJobId?.(asset)
if (override !== undefined) return override
const metadata = getOutputAssetMetadata(asset.user_metadata)
return metadata?.jobId ?? null
}
@@ -76,6 +94,13 @@ export function useOutputStacks({ assets }: UseOutputStacksOptions) {
return
}
if (liveChildren?.(asset) !== undefined) {
const nextExpanded = new Set(expandedStackJobIds.value)
nextExpanded.add(jobId)
expandedStackJobIds.value = nextExpanded
return
}
if (!stackChildrenByJobId.value[jobId]?.length) {
if (loadingStackJobIds.value.has(jobId)) {
return

View File

@@ -0,0 +1,12 @@
/**
* Reserved asset tags used by the assets API.
* Kept dependency-free so components can import them without pulling in
* the asset service's network/i18n import chain.
*/
export const MODELS_TAG = 'models'
export const INPUT_TAG = 'input'
export const OUTPUT_TAG = 'output'
/** Asset tag used by the backend for temporary (preview) workflow outputs. */
export const TEMP_TAG = 'temp'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'

View File

@@ -10,6 +10,7 @@ const zAsset = z.object({
mime_type: z.string().nullish(),
tags: z.array(z.string()).optional().default([]),
preview_id: z.string().nullable().optional(),
job_id: z.string().nullish(),
display_name: z.string().optional(),
preview_url: z.string().optional(),
thumbnail_url: z.string().optional(),

View File

@@ -20,6 +20,7 @@ import type {
ModelFolder,
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import { MISSING_TAG, MODELS_TAG } from '@/platform/assets/constants/assetTags'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
@@ -34,13 +35,29 @@ interface AssetPaginationOptions extends PaginationOptions {
signal?: AbortSignal
}
interface AssetRequestOptions extends PaginationOptions {
export type AssetSortField = 'name' | 'created_at' | 'updated_at' | 'size'
export type AssetSortOrder = 'asc' | 'desc'
export interface AssetListOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
sort?: AssetSortField
order?: AssetSortOrder
signal?: AbortSignal
}
/** Error from the assets API carrying the HTTP status for fallback decisions. */
export class AssetRequestError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
this.name = 'AssetRequestError'
}
}
interface AssetExportOptions {
job_ids?: string[]
asset_ids?: AssetId[]
@@ -180,11 +197,13 @@ const EXPERIMENTAL_WARNING = `EXPERIMENTAL: If you are seeing this please make s
const DEFAULT_LIMIT = 500
const INPUT_ASSETS_WITH_PUBLIC_LIMIT = 500
export const MODELS_TAG = 'models'
export const INPUT_TAG = 'input'
export const OUTPUT_TAG = 'output'
/** Asset tag used by the backend for placeholder records that are not installed. */
export const MISSING_TAG = 'missing'
export {
MODELS_TAG,
INPUT_TAG,
OUTPUT_TAG,
TEMP_TAG,
MISSING_TAG
} from '@/platform/assets/constants/assetTags'
const DEFAULT_EXCLUDED_ASSET_TAGS = [MISSING_TAG]
const uploadedAssetResponseSchema = assetItemSchema.extend({
@@ -278,7 +297,7 @@ function createAssetService() {
* Handles API response with consistent error handling and Zod validation
*/
async function handleAssetRequest(
options: AssetRequestOptions,
options: AssetListOptions,
context: string
): Promise<AssetResponse> {
const {
@@ -287,6 +306,8 @@ function createAssetService() {
limit = DEFAULT_LIMIT,
offset,
includePublic,
sort,
order,
signal
} = options
const normalizedIncludeTags = normalizeAssetTags(includeTags)
@@ -305,19 +326,39 @@ function createAssetService() {
if (includePublic !== undefined) {
queryParams.set('include_public', includePublic ? 'true' : 'false')
}
if (sort !== undefined) {
queryParams.set('sort', sort)
}
if (order !== undefined) {
queryParams.set('order', order)
}
const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}`
const res = signal
? await api.fetchApi(url, { signal })
: await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`
throw new AssetRequestError(
`${EXPERIMENTAL_WARNING}Unable to load ${context}: Server returned ${res.status}. Please try again.`,
res.status
)
}
const data = await res.json()
return validateAssetResponse(data)
}
/**
* Gets one paginated asset response for arbitrary tag/sort options.
* Used by the media sidebar's assets-API path.
*/
async function getAssetsPage(
options: AssetListOptions
): Promise<AssetResponse> {
return await handleAssetRequest(
options,
`assets for tags ${options.includeTags.join(',')}`
)
}
/**
* Gets a list of model folder keys from the asset API
*
@@ -948,6 +989,7 @@ function createAssetService() {
getAssetsForNodeType,
getAssetDetails,
getAssetsByTag,
getAssetsPage,
getAssetsPageByTag,
getAllAssetsByTag,
getInputAssetsIncludingPublic,

View File

@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
compareAssets,
mergeAssetStreams,
pickNextStream
} from '@/platform/assets/utils/assetStreamMerge'
import type {
AssetSortSpec,
AssetStreamState
} from '@/platform/assets/utils/assetStreamMerge'
function asset(id: string, createdAt: string, name = `${id}.png`): AssetItem {
return {
id,
name,
tags: ['output'],
created_at: createdAt
}
}
function stream(
tag: string,
items: AssetItem[],
hasMore = false
): AssetStreamState {
return { tag, items, offset: items.length, hasMore }
}
const NEWEST_FIRST: AssetSortSpec = { sort: 'created_at', order: 'desc' }
const OLDEST_FIRST: AssetSortSpec = { sort: 'created_at', order: 'asc' }
const NAME_ASC: AssetSortSpec = { sort: 'name', order: 'asc' }
describe('compareAssets', () => {
it('orders by created_at descending for newest-first', () => {
const older = asset('a', '2026-01-01T00:00:00Z')
const newer = asset('b', '2026-02-01T00:00:00Z')
expect(compareAssets(newer, older, NEWEST_FIRST)).toBeLessThan(0)
expect(compareAssets(older, newer, NEWEST_FIRST)).toBeGreaterThan(0)
})
it('orders by name ascending for alphabetical sort', () => {
const apple = asset('a', '2026-01-01T00:00:00Z', 'apple.png')
const zebra = asset('b', '2026-01-01T00:00:00Z', 'zebra.png')
expect(compareAssets(apple, zebra, NAME_ASC)).toBeLessThan(0)
})
})
describe('mergeAssetStreams', () => {
it('merges exhausted streams fully, sorted by spec', () => {
const a1 = asset('a1', '2026-03-01T00:00:00Z')
const a2 = asset('a2', '2026-01-01T00:00:00Z')
const b1 = asset('b1', '2026-02-01T00:00:00Z')
const merged = mergeAssetStreams(
[stream('output', [a1, a2]), stream('temp', [b1])],
NEWEST_FIRST
)
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1', 'a2'])
})
it('dedupes assets appearing in multiple streams', () => {
const shared = asset('x', '2026-01-01T00:00:00Z')
const merged = mergeAssetStreams(
[stream('output', [shared]), stream('input', [{ ...shared }])],
NEWEST_FIRST
)
expect(merged).toHaveLength(1)
})
it('holds back items beyond the frontier of an unexhausted stream', () => {
// Stream A loaded down to Feb; stream B (hasMore) only down to March.
// January item from A must wait: B may still hold newer-than-January
// items on its next page.
const a1 = asset('a1', '2026-04-01T00:00:00Z')
const a2 = asset('a2', '2026-02-01T00:00:00Z')
const b1 = asset('b1', '2026-03-01T00:00:00Z')
const merged = mergeAssetStreams(
[stream('output', [a1, a2], false), stream('temp', [b1], true)],
NEWEST_FIRST
)
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1'])
})
it('emits everything when all streams are exhausted', () => {
const a1 = asset('a1', '2026-04-01T00:00:00Z')
const a2 = asset('a2', '2026-02-01T00:00:00Z')
const b1 = asset('b1', '2026-03-01T00:00:00Z')
const merged = mergeAssetStreams(
[stream('output', [a1, a2]), stream('temp', [b1])],
NEWEST_FIRST
)
expect(merged).toHaveLength(3)
})
it('respects ascending order when holding back', () => {
const a1 = asset('a1', '2026-01-01T00:00:00Z')
const a2 = asset('a2', '2026-03-01T00:00:00Z')
const b1 = asset('b1', '2026-02-01T00:00:00Z')
const merged = mergeAssetStreams(
[stream('output', [a1, a2], true), stream('temp', [b1], false)],
OLDEST_FIRST
)
// output stream frontier is March (hasMore), temp is exhausted:
// everything loaded sorts at or before the frontier.
expect(merged.map((a) => a.id)).toEqual(['a1', 'b1', 'a2'])
})
})
describe('pickNextStream', () => {
it('returns -1 when all streams are exhausted', () => {
expect(
pickNextStream(
[stream('output', [asset('a', '2026-01-01T00:00:00Z')])],
NEWEST_FIRST
)
).toBe(-1)
})
it('prefers an unexhausted stream with nothing loaded', () => {
const streams = [
stream('output', [asset('a', '2026-01-01T00:00:00Z')], true),
stream('temp', [], true)
]
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
})
it('advances the stream whose frontier is least far along', () => {
const streams = [
// frontier March — further along under newest-first
stream('output', [asset('a', '2026-03-01T00:00:00Z')], true),
// frontier April — still near the top
stream('temp', [asset('b', '2026-04-01T00:00:00Z')], true)
]
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
})
it('skips exhausted streams', () => {
const streams = [
stream('output', [asset('a', '2026-03-01T00:00:00Z')], false),
stream('temp', [asset('b', '2026-04-01T00:00:00Z')], true)
]
expect(pickNextStream(streams, NEWEST_FIRST)).toBe(1)
})
})

View File

@@ -0,0 +1,117 @@
/**
* Pure helpers for merging multiple paginated asset streams (one per tag)
* into a single list ordered by the active sort key.
*
* The assets API's `include_tags` filter uses AND semantics, so showing
* outputs together with previews (or the All tab) requires one request
* stream per tag, unioned client-side.
*/
import type {
AssetSortField,
AssetSortOrder
} from '@/platform/assets/services/assetService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
export interface AssetSortSpec {
sort: AssetSortField
order: AssetSortOrder
}
export interface AssetStreamState {
tag: string
items: AssetItem[]
offset: number
hasMore: boolean
}
function sortValue(asset: AssetItem, field: AssetSortField): number | string {
switch (field) {
case 'name':
return asset.name
case 'size':
return asset.size ?? 0
case 'updated_at':
return asset.updated_at ? new Date(asset.updated_at).getTime() : 0
case 'created_at':
return asset.created_at ? new Date(asset.created_at).getTime() : 0
}
}
/**
* Compare two assets under a sort spec. Returns negative when `a` comes
* before `b` in display order.
*/
export function compareAssets(
a: AssetItem,
b: AssetItem,
spec: AssetSortSpec
): number {
const va = sortValue(a, spec.sort)
const vb = sortValue(b, spec.sort)
const cmp =
typeof va === 'string' && typeof vb === 'string'
? va.localeCompare(vb)
: Number(va) - Number(vb)
return spec.order === 'asc' ? cmp : -cmp
}
/**
* Merge stream items into one deduped list ordered by the sort spec.
*
* While any stream still has more pages, items sorting after that stream's
* frontier (its last loaded item) are held back: they could be preceded by
* not-yet-loaded items from that stream, so emitting them would show gaps.
*/
export function mergeAssetStreams(
streams: AssetStreamState[],
spec: AssetSortSpec
): AssetItem[] {
const seen = new Set<string>()
const merged: AssetItem[] = []
for (const stream of streams) {
for (const item of stream.items) {
if (seen.has(item.id)) continue
seen.add(item.id)
merged.push(item)
}
}
merged.sort((a, b) => compareAssets(a, b, spec))
const frontiers = streams
.filter((stream) => stream.hasMore && stream.items.length > 0)
.map((stream) => stream.items[stream.items.length - 1])
if (frontiers.length === 0) return merged
const limit = frontiers.reduce((min, frontier) =>
compareAssets(frontier, min, spec) < 0 ? frontier : min
)
return merged.filter((item) => compareAssets(item, limit, spec) <= 0)
}
/**
* Pick the stream to advance on loadMore: the unexhausted stream whose
* frontier is least advanced in display order (an unexhausted stream with
* nothing loaded yet always wins). Returns -1 when all streams are done.
*/
export function pickNextStream(
streams: AssetStreamState[],
spec: AssetSortSpec
): number {
let best = -1
for (let i = 0; i < streams.length; i++) {
const stream = streams[i]
if (!stream.hasMore) continue
if (stream.items.length === 0) return i
if (best === -1) {
best = i
continue
}
const bestItems = streams[best].items
const frontier = stream.items[stream.items.length - 1]
const bestFrontier = bestItems[bestItems.length - 1]
if (compareAssets(frontier, bestFrontier, spec) < 0) {
best = i
}
}
return best
}

View File

@@ -29,10 +29,13 @@ export async function extractWorkflowFromAsset(asset: AssetItem): Promise<{
}> {
const baseFilename = asset.name.replace(/\.[^/.]+$/, '.json')
// For output assets: use jobs API (with caching and validation)
const metadata = getOutputAssetMetadata(asset.user_metadata)
if (metadata?.jobId) {
const workflow = await getJobWorkflow(metadata.jobId)
// For output assets: use jobs API (with caching and validation).
// History-mapped assets carry the job id in user_metadata; assets-API
// records expose it as job_id.
const jobId =
getOutputAssetMetadata(asset.user_metadata)?.jobId ?? asset.job_id
if (jobId) {
const workflow = await getJobWorkflow(jobId)
return { workflow: workflow ?? null, filename: baseFilename }
}

View File

@@ -7,7 +7,10 @@ import { useAssetsStore } from '@/stores/assetsStore'
import { api } from '@/scripts/api'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { assetService } from '@/platform/assets/services/assetService'
import {
AssetRequestError,
assetService
} from '@/platform/assets/services/assetService'
// Mock the api module
vi.mock('@/scripts/api', () => ({
@@ -27,11 +30,20 @@ vi.mock('@/platform/assets/services/assetService', () => ({
getAssetsByTag: vi.fn(),
getAllAssetsByTag: vi.fn(),
getAssetsForNodeType: vi.fn(),
getAssetsPage: vi.fn(),
invalidateInputAssetsIncludingPublic: vi.fn(),
updateAsset: vi.fn(),
addAssetTags: vi.fn(),
removeAssetTags: vi.fn()
},
AssetRequestError: class AssetRequestError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message)
}
},
INPUT_TAG: 'input',
OUTPUT_TAG: 'output'
}))
@@ -1635,3 +1647,221 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1'])
})
})
describe('assetsStore - Assets API union streams', () => {
const makeAsset = (
id: string,
createdAt: string,
tags: string[] = ['output']
): AssetItem => ({
id,
name: `${id}.png`,
size: 0,
tags,
created_at: createdAt
})
const page = (assets: AssetItem[], hasMore = false) => ({
assets,
total: assets.length,
has_more: hasMore
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('fetches one stream per tag and merges by sort key', async () => {
vi.mocked(assetService.getAssetsPage).mockImplementation(
async ({ includeTags }) => {
if (includeTags[0] === 'output') {
return page([
makeAsset('o1', '2026-03-01T00:00:00Z'),
makeAsset('o2', '2026-01-01T00:00:00Z')
])
}
return page([makeAsset('t1', '2026-02-01T00:00:00Z', ['temp'])])
}
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['output', 'temp'],
sort: 'created_at',
order: 'desc'
})
expect(assetService.getAssetsPage).toHaveBeenCalledTimes(2)
expect(
vi
.mocked(assetService.getAssetsPage)
.mock.calls.map(([opts]) => opts.includeTags)
).toEqual([['output'], ['temp']])
expect(store.apiAssets.map((a) => a.id)).toEqual(['o1', 't1', 'o2'])
expect(store.apiHasMore).toBe(false)
expect(store.apiLoading).toBe(false)
})
it('passes sort and order through to the service', async () => {
vi.mocked(assetService.getAssetsPage).mockResolvedValue(page([]))
const store = useAssetsStore()
await store.fetchApiAssets({ tags: ['input'], sort: 'name', order: 'asc' })
expect(assetService.getAssetsPage).toHaveBeenCalledWith(
expect.objectContaining({ sort: 'name', order: 'asc', offset: 0 })
)
})
it('loadMore advances the least-advanced stream and dedupes', async () => {
vi.mocked(assetService.getAssetsPage).mockImplementation(
async ({ includeTags, offset }) => {
if (includeTags[0] === 'output') {
// First page exhausts the output stream at March.
return page([makeAsset('o1', '2026-03-01T00:00:00Z')])
}
if (offset === undefined || offset === 0) {
return page([makeAsset('t1', '2026-04-01T00:00:00Z', ['temp'])], true)
}
return page([
// Duplicate of t1 (offset drift) plus one genuinely new item.
makeAsset('t1', '2026-04-01T00:00:00Z', ['temp']),
makeAsset('t2', '2026-02-01T00:00:00Z', ['temp'])
])
}
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['output', 'temp'],
sort: 'created_at',
order: 'desc'
})
// temp stream has more and its frontier (April) is least advanced:
// o1 (March) is held back until temp catches up.
expect(store.apiAssets.map((a) => a.id)).toEqual(['t1'])
expect(store.apiHasMore).toBe(true)
await store.loadMoreApiAssets()
expect(store.apiAssets.map((a) => a.id)).toEqual(['t1', 'o1', 't2'])
expect(store.apiHasMore).toBe(false)
})
it('flags the API unavailable on 503', async () => {
vi.mocked(assetService.getAssetsPage).mockRejectedValue(
new AssetRequestError('unavailable', 503)
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['output'],
sort: 'created_at',
order: 'desc'
})
expect(store.assetApiUnavailable).toBe(true)
expect(store.apiError).toBeInstanceOf(AssetRequestError)
})
it('does not flag unavailability on transient server errors', async () => {
vi.mocked(assetService.getAssetsPage).mockRejectedValue(
new AssetRequestError('boom', 500)
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['output'],
sort: 'created_at',
order: 'desc'
})
expect(store.assetApiUnavailable).toBe(false)
expect(store.apiError).toBeInstanceOf(AssetRequestError)
})
it('refreshApiAssets refetches the same streams from the start', async () => {
vi.mocked(assetService.getAssetsPage)
.mockResolvedValueOnce(page([makeAsset('o1', '2026-02-01T00:00:00Z')]))
.mockResolvedValueOnce(
page([
makeAsset('o2', '2026-03-01T00:00:00Z'),
makeAsset('o1', '2026-02-01T00:00:00Z')
])
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['output'],
sort: 'created_at',
order: 'desc'
})
await store.refreshApiAssets()
expect(assetService.getAssetsPage).toHaveBeenLastCalledWith(
expect.objectContaining({
includeTags: ['output'],
sort: 'created_at',
order: 'desc',
offset: 0
})
)
expect(store.apiAssets.map((a) => a.id)).toEqual(['o2', 'o1'])
})
it('refreshApiAssets is a no-op before the first fetch', async () => {
const store = useAssetsStore()
await store.refreshApiAssets()
expect(assetService.getAssetsPage).not.toHaveBeenCalled()
})
it('patchApiAsset updates loaded assets in place', async () => {
vi.mocked(assetService.getAssetsPage).mockResolvedValue(
page([makeAsset('t1', '2026-02-01T00:00:00Z', ['temp'])])
)
const store = useAssetsStore()
await store.fetchApiAssets({
tags: ['temp'],
sort: 'created_at',
order: 'desc'
})
store.patchApiAsset('t1', { tags: ['output'] })
expect(store.apiAssets[0].tags).toEqual(['output'])
})
it('ignores stale responses when a newer fetch supersedes', async () => {
let resolveFirst!: (value: {
assets: AssetItem[]
total: number
has_more: boolean
}) => void
vi.mocked(assetService.getAssetsPage)
.mockImplementationOnce(
() =>
new Promise((res) => {
resolveFirst = res
})
)
.mockResolvedValueOnce(page([makeAsset('b1', '2026-01-01T00:00:00Z')]))
const store = useAssetsStore()
const first = store.fetchApiAssets({
tags: ['output'],
sort: 'created_at',
order: 'desc'
})
const second = store.fetchApiAssets({
tags: ['input'],
sort: 'created_at',
order: 'desc'
})
resolveFirst(page([makeAsset('a1', '2026-02-01T00:00:00Z')]))
await Promise.all([first, second])
expect(store.apiAssets.map((a) => a.id)).toEqual(['b1'])
})
})

View File

@@ -11,12 +11,26 @@ import type {
TagsOperationResult
} from '@/platform/assets/schemas/assetSchema'
import {
AssetRequestError,
INPUT_TAG,
OUTPUT_TAG,
assetService
} from '@/platform/assets/services/assetService'
import type { PaginationOptions } from '@/platform/assets/services/assetService'
import type {
AssetSortField,
AssetSortOrder,
PaginationOptions
} from '@/platform/assets/services/assetService'
import {
mergeAssetStreams,
pickNextStream
} from '@/platform/assets/utils/assetStreamMerge'
import type {
AssetSortSpec,
AssetStreamState
} from '@/platform/assets/utils/assetStreamMerge'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes'
import { api } from '@/scripts/api'
@@ -319,6 +333,144 @@ export const useAssetsStore = defineStore('assets', () => {
await fetchFlatOutputs(true)
}
// Assets-API path for the media sidebar: one paginated stream per tag
// (include_tags is AND semantics, so output+temp / All need a client-side
// union), merged by the active sort key and deduped by asset id.
const UNION_PAGE_SIZE = 100
const apiAssets = ref<AssetItem[]>([])
const apiLoading = ref(false)
const apiError = ref<unknown>(null)
const apiHasMore = ref(false)
const apiIsLoadingMore = ref(false)
/**
* Session-scoped flag set when the assets API is unreachable (503 from OSS
* servers without --enable-assets, 404 from older servers). The sidebar
* falls back to the history path for the rest of the session.
*/
const assetApiUnavailable = ref(false)
/**
* Whether the media sidebar is on the assets-API data source: setting on
* and the API not detected as unavailable this session.
*/
const assetApiSourceActive = computed(
() =>
!!useSettingStore().get('Comfy.Assets.UseAssetAPI') &&
!assetApiUnavailable.value
)
let apiStreams: AssetStreamState[] = []
let apiSortSpec: AssetSortSpec = { sort: 'created_at', order: 'desc' }
let apiRequestId = 0
async function fetchStreamPage(
stream: AssetStreamState,
spec: AssetSortSpec
): Promise<void> {
const page = await assetService.getAssetsPage({
includeTags: [stream.tag],
limit: UNION_PAGE_SIZE,
offset: stream.offset,
sort: spec.sort,
order: spec.order
})
stream.items.push(...page.assets)
stream.offset += page.assets.length
stream.hasMore = page.has_more && page.assets.length > 0
}
function syncApiAssets() {
apiAssets.value = mergeAssetStreams(apiStreams, apiSortSpec)
apiHasMore.value = apiStreams.some((stream) => stream.hasMore)
}
/**
* (Re)load the union streams for the given tags and sort. Replaces any
* in-flight or previous result.
*/
async function fetchApiAssets(params: {
tags: string[]
sort: AssetSortField
order: AssetSortOrder
}): Promise<void> {
const requestId = ++apiRequestId
const spec: AssetSortSpec = { sort: params.sort, order: params.order }
const streams: AssetStreamState[] = params.tags.map((tag) => ({
tag,
items: [],
offset: 0,
hasMore: true
}))
apiLoading.value = true
apiError.value = null
try {
await Promise.all(streams.map((stream) => fetchStreamPage(stream, spec)))
if (requestId !== apiRequestId) return
apiStreams = streams
apiSortSpec = spec
syncApiAssets()
} catch (err) {
if (requestId !== apiRequestId) return
apiError.value = err
if (
err instanceof AssetRequestError &&
(err.status === 503 || err.status === 404)
) {
assetApiUnavailable.value = true
}
console.error('Failed to fetch assets from assets API:', err)
} finally {
if (requestId === apiRequestId) apiLoading.value = false
}
}
/**
* Refetch the current streams from the start with the same tags and sort.
* Used when new assets may exist server-side (e.g. a job completed).
* No-op until the sidebar has performed its first fetch.
*/
async function refreshApiAssets(): Promise<void> {
if (apiStreams.length === 0) return
await fetchApiAssets({
tags: apiStreams.map((stream) => stream.tag),
sort: apiSortSpec.sort,
order: apiSortSpec.order
})
}
/** Advance the stream whose frontier is least far along in sort order. */
async function loadMoreApiAssets(): Promise<void> {
if (apiLoading.value || apiIsLoadingMore.value) return
const index = pickNextStream(apiStreams, apiSortSpec)
if (index === -1) return
const requestId = apiRequestId
apiIsLoadingMore.value = true
try {
await fetchStreamPage(apiStreams[index], apiSortSpec)
if (requestId !== apiRequestId) return
syncApiAssets()
} catch (err) {
if (requestId !== apiRequestId) return
apiError.value = err
console.error('Failed to load more assets from assets API:', err)
} finally {
apiIsLoadingMore.value = false
}
}
/** Optimistically patch an asset across loaded streams (e.g. tag changes). */
function patchApiAsset(id: string, updates: Partial<AssetItem>): void {
for (const stream of apiStreams) {
stream.items = stream.items.map((item) =>
item.id === id ? { ...item, ...updates } : item
)
}
syncApiAssets()
}
/**
* Patch preview_id/preview_url for a single asset already in memory,
* matched by name. Used after persistThumbnail succeeds so an open Asset
@@ -875,6 +1027,19 @@ export const useAssetsStore = defineStore('assets', () => {
loadMoreHistory,
setAssetPreview,
// Assets-API union streams (media sidebar)
apiAssets,
apiLoading,
apiError,
apiHasMore,
apiIsLoadingMore,
assetApiUnavailable,
assetApiSourceActive,
fetchApiAssets,
refreshApiAssets,
loadMoreApiAssets,
patchApiAsset,
// Flat output assets (cloud-only, tag-based)
flatOutputAssets,
flatOutputLoading,

View File

@@ -231,23 +231,30 @@ useQueuePolling()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const sidebarTabStore = useSidebarTabStore()
// Only update assets if the assets sidebar is currently open (it refreshes
// on mount otherwise). Linear mode always consumes history-shaped assets;
// the sidebar consumes whichever data source is active.
const refreshOpenAssetViews = async () => {
const assetsTabOpen = sidebarTabStore.activeSidebarTabId === 'assets'
if (!assetsTabOpen && !linearMode.value) return
if (linearMode.value || !assetsStore.assetApiSourceActive) {
await assetsStore.updateHistory()
}
if (assetsTabOpen && assetsStore.assetApiSourceActive) {
await assetsStore.refreshApiAssets()
}
}
const onStatus = async (e: CustomEvent<StatusWsMessageStatus>) => {
queuePendingTaskCountStore.update(e)
await queueStore.update()
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.updateHistory()
}
await refreshOpenAssetViews()
}
const onExecutionSuccess = async () => {
await queueStore.update()
// Only update assets if the assets sidebar is currently open
// When sidebar is closed, AssetsSidebarTab.vue will refresh on mount
if (sidebarTabStore.activeSidebarTabId === 'assets' || linearMode.value) {
await assetsStore.updateHistory()
}
await refreshOpenAssetViews()
}
const { onReconnecting, onReconnected } = useReconnectingNotification()