Compare commits

...

8 Commits

Author SHA1 Message Date
Jin Yi
cf2e2bfcbb Merge branch 'main' into media/viewsetting-button 2026-03-10 13:01:33 +09:00
GitHub Action
a3a35aa93e [automated] Apply ESLint and Oxfmt fixes 2026-03-09 12:09:16 +00:00
Jin Yi
7097918650 fix: prevent cache poisoning on aborted requests in useCachedRequest 2026-03-09 21:06:15 +09:00
Jin Yi
0fde4d7c50 refactor: propagate AbortSignal through useUngroupedAssets call chain 2026-03-09 21:06:15 +09:00
Jin Yi
a54365b1f0 refactor: apply code review feedback for media asset sidebar 2026-03-09 21:06:15 +09:00
Jin Yi
3fc47e25f5 refactor: apply code review 2026-03-09 21:06:15 +09:00
Jin Yi
9900c2978c perf: parallelize multi-output asset resolution in useUngroupedAssets
Fire all cachedResolve requests concurrently before awaiting results,
reducing N×latency to ~1×latency via useCachedRequest deduplication.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 21:06:15 +09:00
Jin Yi
56905c75ab feat: add group-by-job, show-names, show-details toggles to media asset settings 2026-03-09 21:06:15 +09:00
19 changed files with 499 additions and 38 deletions

View File

@@ -49,9 +49,13 @@
:preview-alt="getAssetDisplayName(item.asset)"
:icon-name="iconForMediaType(getAssetMediaType(item.asset))"
:is-video-preview="isVideoAsset(item.asset)"
:primary-text="getAssetPrimaryText(item.asset)"
:secondary-text="getAssetSecondaryText(item.asset)"
:stack-count="getStackCount(item.asset)"
:primary-text="
showAssetNames ? getAssetPrimaryText(item.asset) : undefined
"
:secondary-text="
showAssetDetails ? getAssetSecondaryText(item.asset) : undefined
"
:stack-count="groupByJob ? getStackCount(item.asset) : undefined"
:stack-indicator-label="t('mediaAsset.actions.seeMoreOutputs')"
:stack-expanded="isStackExpanded(item.asset)"
@mouseenter="onAssetEnter(item.asset.id)"
@@ -106,7 +110,10 @@ const {
isSelected,
isStackExpanded,
toggleStack,
assetType = 'output'
assetType = 'output',
groupByJob = true,
showAssetNames = true,
showAssetDetails = true
} = defineProps<{
assetItems: OutputStackListItem[]
selectableAssets: AssetItem[]
@@ -114,6 +121,9 @@ const {
isStackExpanded: (asset: AssetItem) => boolean
toggleStack: (asset: AssetItem) => Promise<void>
assetType?: 'input' | 'output'
groupByJob?: boolean
showAssetNames?: boolean
showAssetDetails?: boolean
}>()
const assetsStore = useAssetsStore()

View File

@@ -50,6 +50,9 @@
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
v-model:media-type-filters="mediaTypeFilters"
v-model:group-by-job="groupByJob"
v-model:show-asset-names="showAssetNames"
v-model:show-asset-details="showAssetDetails"
class="px-2 pb-1 2xl:px-4"
:show-generation-time-sort="activeTab === 'output'"
/>
@@ -88,12 +91,17 @@
<div v-else class="relative size-full" @click="handleEmptySpaceClick">
<AssetsSidebarListView
v-if="isListView"
:asset-items="listViewAssetItems"
:asset-items="
groupByJob ? listViewAssetItems : ungroupedListViewItems
"
:is-selected="isSelected"
:selectable-assets="listViewSelectableAssets"
:selectable-assets="listViewVisibleAssets"
:is-stack-expanded="isListViewStackExpanded"
:toggle-stack="toggleListViewStack"
:asset-type="activeTab"
:group-by-job="groupByJob"
:show-asset-names="showAssetNames"
:show-asset-details="showAssetDetails"
@select-asset="handleAssetSelect"
@preview-asset="handleZoomClick"
@context-menu="handleAssetContextMenu"
@@ -101,7 +109,7 @@
/>
<AssetsSidebarGridView
v-else
:assets="displayAssets"
:assets="ungroupedAssets"
:is-selected="isSelected"
:asset-type="activeTab"
:show-output-count="shouldShowOutputCount"
@@ -211,6 +219,7 @@ import {
nextTick,
onMounted,
onUnmounted,
provide,
ref,
watch
} from 'vue'
@@ -232,11 +241,17 @@ import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAsse
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
import { useMediaAssetActions } from '@/platform/assets/composables/useMediaAssetActions'
import { useMediaAssetFiltering } from '@/platform/assets/composables/useMediaAssetFiltering'
import type { OutputStackListItem } from '@/platform/assets/composables/useOutputStacks'
import { useOutputStacks } from '@/platform/assets/composables/useOutputStacks'
import { useUngroupedAssets } from '@/platform/assets/composables/useUngroupedAssets'
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'
import {
ShowAssetDetailsKey,
ShowAssetNamesKey
} from '@/platform/assets/schemas/mediaAssetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
import { isCloud } from '@/platform/distribution/types'
import { useDialogStore } from '@/stores/dialogStore'
@@ -266,6 +281,14 @@ const viewMode = useStorage<'list' | 'grid'>(
'grid'
)
const isListView = computed(() => viewMode.value === 'list')
const groupByJob = useStorage('Comfy.Assets.Sidebar.GroupByJob', true)
const showAssetNames = useStorage('Comfy.Assets.Sidebar.ShowAssetNames', true)
const showAssetDetails = useStorage(
'Comfy.Assets.Sidebar.ShowAssetDetails',
true
)
provide(ShowAssetNamesKey, showAssetNames)
provide(ShowAssetDetailsKey, showAssetDetails)
const contextMenuRef = ref<InstanceType<typeof MediaAssetContextMenu>>()
const contextMenuAsset = ref<AssetItem | null>(null)
@@ -286,6 +309,7 @@ const contextMenuFileKind = computed<MediaKind>(() =>
)
const shouldShowOutputCount = (item: AssetItem): boolean => {
if (!groupByJob.value) return false
if (activeTab.value !== 'output' || isInFolderView.value) {
return false
}
@@ -392,9 +416,10 @@ const baseAssets = computed(() => {
const { searchQuery, sortBy, mediaTypeFilters, filteredAssets } =
useMediaAssetFiltering(baseAssets)
const displayAssets = computed(() => {
return filteredAssets.value
})
const { ungroupedAssets, isResolving } = useUngroupedAssets(
filteredAssets,
groupByJob
)
const {
assetItems: listViewAssetItems,
@@ -402,11 +427,22 @@ const {
isStackExpanded: isListViewStackExpanded,
toggleStack: toggleListViewStack
} = useOutputStacks({
assets: computed(() => displayAssets.value)
assets: filteredAssets
})
const ungroupedListViewItems = computed<OutputStackListItem[]>(() =>
ungroupedAssets.value.map((asset) => ({
key: `asset-${asset.id}`,
asset
}))
)
const listViewVisibleAssets = computed(() =>
groupByJob.value ? listViewSelectableAssets.value : ungroupedAssets.value
)
const visibleAssets = computed(() => {
if (!isListView.value) return displayAssets.value
if (!isListView.value || !groupByJob.value) return ungroupedAssets.value
return listViewSelectableAssets.value
})
@@ -426,14 +462,20 @@ const isFolderLoading = computed(
() => isInFolderView.value && folderLoading.value
)
const showLoadingState = computed(
() =>
(loading.value || isFolderLoading.value) && displayAssets.value.length === 0
)
const showLoadingState = computed(() => {
const isPrimaryLoading =
(loading.value || isFolderLoading.value) &&
filteredAssets.value.length === 0
const isUngroupedResolvingEmpty =
!groupByJob.value && isResolving.value && ungroupedAssets.value.length === 0
return isPrimaryLoading || isUngroupedResolvingEmpty
})
const showEmptyState = computed(
() =>
!loading.value && !isFolderLoading.value && displayAssets.value.length === 0
!loading.value &&
!isFolderLoading.value &&
filteredAssets.value.length === 0
)
watch(visibleAssets, (newAssets) => {

View File

@@ -239,4 +239,32 @@ describe('useCachedRequest', () => {
await cachedRequest.call(123)
expect(mockRequestFn).toHaveBeenCalledTimes(1)
})
it('should not cache aborted requests', async () => {
vi.unstubAllGlobals()
let callCount = 0
const abortFn = vi.fn(async (_params: unknown, _signal?: AbortSignal) => {
callCount++
if (callCount === 1) {
const error = new DOMException(
'The operation was aborted.',
'AbortError'
)
throw error
}
return { data: 'success' }
})
const cachedRequest = useCachedRequest(abortFn)
// First call throws AbortError — should NOT be cached
const result1 = await cachedRequest.call('key')
expect(result1).toBeNull()
// Second call should retry (not use cached null)
const result2 = await cachedRequest.call('key')
expect(result2).toEqual({ data: 'success' })
expect(abortFn).toHaveBeenCalledTimes(2)
})
})

View File

@@ -48,6 +48,10 @@ export function useCachedRequest<TParams, TResult>(
return result
} catch (err) {
// Don't cache aborted requests — they should be retried
if (err instanceof DOMException && err.name === 'AbortError') {
return null
}
// Set cache on error to prevent retrying bad requests
cache.set(cacheKey, null)
return null

View File

@@ -763,7 +763,10 @@
"filterAudio": "Audio",
"filter3D": "3D",
"filterText": "Text",
"viewSettings": "View settings"
"viewSettings": "View settings",
"groupByJob": "Group assets by job",
"showAssetNames": "Show asset names",
"showAssetDetails": "Show asset details"
},
"backToAssets": "Back to all assets",
"folderView": {

View File

@@ -105,10 +105,11 @@
>
<!-- Left side: Media name and metadata -->
<div class="flex flex-col gap-1">
<!-- Title -->
<MediaTitle :file-name="fileName" />
<!-- Metadata -->
<div class="flex gap-1.5 text-xs text-muted-foreground">
<MediaTitle v-if="showAssetNames" :file-name="fileName" />
<div
v-if="showAssetDetails"
class="flex gap-1.5 text-xs text-muted-foreground"
>
<span v-if="formattedDuration">{{ formattedDuration }}</span>
<span v-if="metaInfo">{{ metaInfo }}</span>
</div>
@@ -134,7 +135,14 @@
<script setup lang="ts">
import { useElementHover } from '@vueuse/core'
import { computed, defineAsyncComponent, provide, ref, toRef } from 'vue'
import {
computed,
defineAsyncComponent,
inject,
provide,
ref,
toRef
} from 'vue'
import IconGroup from '@/components/button/IconGroup.vue'
import LoadingOverlay from '@/components/common/LoadingOverlay.vue'
@@ -153,7 +161,11 @@ import { getAssetType } from '../composables/media/assetMappers'
import { useMediaAssetActions } from '../composables/useMediaAssetActions'
import type { AssetItem } from '../schemas/assetSchema'
import type { MediaKind } from '../schemas/mediaAssetSchema'
import { MediaAssetKey } from '../schemas/mediaAssetSchema'
import {
MediaAssetKey,
ShowAssetDetailsKey,
ShowAssetNamesKey
} from '../schemas/mediaAssetSchema'
import MediaTitle from './MediaTitle.vue'
type PreviewKind = ReturnType<typeof getMediaTypeFromFilename>
@@ -181,6 +193,9 @@ const { asset, loading, selected, showOutputCount, outputCount } = defineProps<{
outputCount?: number
}>()
const showAssetNames = inject(ShowAssetNamesKey, ref(true))
const showAssetDetails = inject(ShowAssetDetailsKey, ref(true))
const assetsStore = useAssetsStore()
// Get deletion state from store

View File

@@ -1,5 +1,5 @@
<template>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 px-3 pt-2 2xl:px-4">
<SearchInput
:model-value="searchQuery"
:placeholder="
@@ -27,6 +27,9 @@
<MediaAssetSettingsMenu
v-model:view-mode="viewMode"
v-model:sort-by="sortBy"
v-model:group-by-job="groupByJob"
v-model:show-asset-names="showAssetNames"
v-model:show-asset-details="showAssetDetails"
:show-sort-options="isCloud"
:show-generation-time-sort
/>
@@ -59,6 +62,13 @@ const emit = defineEmits<{
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const groupByJob = defineModel<boolean>('groupByJob', { required: true })
const showAssetNames = defineModel<boolean>('showAssetNames', {
required: true
})
const showAssetDetails = defineModel<boolean>('showAssetDetails', {
required: true
})
const handleSearchChange = (value: string | undefined) => {
emit('update:searchQuery', value ?? '')

View File

@@ -30,6 +30,49 @@
/>
</Button>
<div class="my-1 w-full border-b border-border-subtle" />
<Button
variant="textonly"
class="w-full"
:aria-pressed="groupByJob"
@click="groupByJob = !groupByJob"
>
<span>{{ $t('sideToolbar.mediaAssets.groupByJob') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="!groupByJob && 'opacity-0'"
/>
</Button>
<div class="my-1 w-full border-b border-border-subtle" />
<Button
variant="textonly"
class="w-full"
:aria-pressed="showAssetNames"
@click="showAssetNames = !showAssetNames"
>
<span>{{ $t('sideToolbar.mediaAssets.showAssetNames') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="!showAssetNames && 'opacity-0'"
/>
</Button>
<Button
variant="textonly"
class="w-full"
:aria-pressed="showAssetDetails"
@click="showAssetDetails = !showAssetDetails"
>
<span>{{ $t('sideToolbar.mediaAssets.showAssetDetails') }}</span>
<i
class="ml-auto icon-[lucide--check] size-4"
:class="!showAssetDetails && 'opacity-0'"
/>
</Button>
<template v-if="showSortOptions">
<div class="my-1 w-full border-b border-border-subtle" />
@@ -99,6 +142,13 @@ const { showSortOptions = false, showGenerationTimeSort = false } =
const viewMode = defineModel<'list' | 'grid'>('viewMode', { required: true })
const sortBy = defineModel<SortBy>('sortBy', { required: true })
const groupByJob = defineModel<boolean>('groupByJob', { required: true })
const showAssetNames = defineModel<boolean>('showAssetNames', {
required: true
})
const showAssetDetails = defineModel<boolean>('showAssetDetails', {
required: true
})
function handleViewModeChange(value: 'list' | 'grid') {
viewMode.value = value

View File

@@ -0,0 +1,212 @@
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type * as OutputAssetUtil from '@/platform/assets/utils/outputAssetUtil'
import { useUngroupedAssets } from './useUngroupedAssets'
const mocks = vi.hoisted(() => ({
resolveOutputAssetItems: vi.fn()
}))
vi.mock('@/platform/assets/utils/outputAssetUtil', async (importOriginal) => {
const actual = await importOriginal<typeof OutputAssetUtil>()
return {
...actual,
resolveOutputAssetItems: mocks.resolveOutputAssetItems
}
})
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-1',
name: 'image.png',
tags: [],
created_at: '2025-01-01T00:00:00.000Z',
user_metadata: undefined,
...overrides
}
}
function createMultiOutputAsset(
jobId: string,
outputCount: number,
overrides: Partial<AssetItem> = {}
): AssetItem {
return createAsset({
id: `asset-${jobId}`,
name: `${jobId}.png`,
user_metadata: {
jobId,
nodeId: 'node-1',
subfolder: 'outputs',
outputCount
},
...overrides
})
}
describe('useUngroupedAssets', () => {
beforeEach(() => {
vi.resetAllMocks()
})
it('returns assets as-is when groupByJob is true', async () => {
const asset = createAsset()
const assets = ref([asset])
const groupByJob = ref(true)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([asset])
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
})
it('returns single-output assets as-is when ungrouped', async () => {
const singleOutput = createAsset({ id: 'single' })
const singleCountAsset = createMultiOutputAsset('job-1', 1)
const assets = ref([singleOutput, singleCountAsset])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([singleOutput, singleCountAsset])
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
})
it('resolves multi-output assets into individual children', async () => {
const parent = createMultiOutputAsset('job-1', 3)
const childA = createAsset({ id: 'child-a', name: 'child-a.png' })
const childB = createAsset({ id: 'child-b', name: 'child-b.png' })
const childC = createAsset({ id: 'child-c', name: 'child-c.png' })
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB, childC])
const assets = ref([parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([childA, childB, childC])
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledWith(
expect.objectContaining({ jobId: 'job-1' }),
{ createdAt: parent.created_at, signal: expect.any(AbortSignal) }
)
})
it('mixes single and multi-output assets correctly', async () => {
const single = createAsset({ id: 'single', name: 'single.png' })
const parent = createMultiOutputAsset('job-2', 2)
const childA = createAsset({ id: 'child-a' })
const childB = createAsset({ id: 'child-b' })
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB])
const assets = ref([single, parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([single, childA, childB])
})
it('falls back to original asset when resolution returns null', async () => {
const parent = createMultiOutputAsset('job-1', 3)
mocks.resolveOutputAssetItems.mockResolvedValue(null)
const assets = ref([parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([parent])
})
it('falls back to original asset when resolution returns empty array', async () => {
const parent = createMultiOutputAsset('job-empty', 3)
mocks.resolveOutputAssetItems.mockResolvedValue([])
const assets = ref([parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([parent])
})
it('triggers resolution when groupByJob toggles from true to false', async () => {
const parent = createMultiOutputAsset('job-1', 2)
const childA = createAsset({ id: 'child-a' })
const childB = createAsset({ id: 'child-b' })
mocks.resolveOutputAssetItems.mockResolvedValue([childA, childB])
const assets = ref([parent])
const groupByJob = ref(true)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(ungroupedAssets.value).toEqual([parent])
expect(mocks.resolveOutputAssetItems).not.toHaveBeenCalled()
groupByJob.value = false
await nextTick()
await flushPromises()
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(ungroupedAssets.value).toEqual([childA, childB])
})
it('does not re-resolve cached jobIds on repeated toggles', async () => {
const parent = createMultiOutputAsset('job-1', 2)
const children = [
createAsset({ id: 'child-a' }),
createAsset({ id: 'child-b' })
]
mocks.resolveOutputAssetItems.mockResolvedValue(children)
const assets = ref([parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(ungroupedAssets.value).toEqual(children)
// Toggle back and forth
groupByJob.value = true
await nextTick()
await flushPromises()
groupByJob.value = false
await nextTick()
await flushPromises()
// useCachedRequest should return cached result
expect(mocks.resolveOutputAssetItems).toHaveBeenCalledTimes(1)
expect(ungroupedAssets.value).toEqual(children)
})
it('falls back to original asset when resolution rejects', async () => {
const parent = createMultiOutputAsset('job-err', 3)
mocks.resolveOutputAssetItems.mockRejectedValue(new Error('network error'))
const assets = ref([parent])
const groupByJob = ref(false)
const { ungroupedAssets } = useUngroupedAssets(assets, groupByJob)
await flushPromises()
// useCachedRequest converts errors to null, fallback to [asset]
expect(ungroupedAssets.value).toEqual([parent])
})
})

View File

@@ -0,0 +1,71 @@
import { computedAsync } from '@vueuse/core'
import { computed, ref } from 'vue'
import type { Ref } from 'vue'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveOutputAssetItems } from '@/platform/assets/utils/outputAssetUtil'
export function useUngroupedAssets(
assets: Ref<AssetItem[]>,
groupByJob: Ref<boolean>
) {
const { call: cachedResolve } = useCachedRequest(
(jobId: string, signal?: AbortSignal) => {
const asset = assets.value.find((a) => {
const m = getOutputAssetMetadata(a.user_metadata)
return m?.jobId === jobId
})
if (!asset) return Promise.resolve(null)
const metadata = getOutputAssetMetadata(asset.user_metadata)!
return resolveOutputAssetItems(metadata, {
createdAt: asset.created_at,
signal
})
}
)
const isResolving = ref(false)
const resolvedAssets = computedAsync(
async () => {
if (groupByJob.value) return []
const entries = assets.value.map((asset) => ({
asset,
metadata: getOutputAssetMetadata(asset.user_metadata)
}))
for (const { metadata } of entries) {
if ((metadata?.outputCount ?? 1) > 1 && metadata?.jobId) {
void cachedResolve(metadata.jobId).catch(() => {})
}
}
const result: AssetItem[] = []
for (const { asset, metadata } of entries) {
const count = metadata?.outputCount ?? 1
if (count <= 1 || !metadata?.jobId) {
result.push(asset)
continue
}
try {
const children = await cachedResolve(metadata.jobId)
result.push(...(children?.length ? children : [asset]))
} catch {
result.push(asset)
}
}
return result
},
[],
isResolving
)
const ungroupedAssets = computed(() =>
groupByJob.value ? assets.value : resolvedAssets.value
)
return { ungroupedAssets, isResolving }
}

View File

@@ -49,3 +49,9 @@ interface MediaAssetProviderValue {
export const MediaAssetKey: InjectionKey<MediaAssetProviderValue> =
Symbol('mediaAsset')
export const ShowAssetNamesKey: InjectionKey<Ref<boolean>> =
Symbol('showAssetNames')
export const ShowAssetDetailsKey: InjectionKey<Ref<boolean>> =
Symbol('showAssetDetails')

View File

@@ -117,7 +117,7 @@ describe('resolveOutputAssetItems', () => {
const results = await resolveOutputAssetItems(metadata)
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2')
expect(mocks.getJobDetail).toHaveBeenCalledWith('job-2', undefined)
expect(mocks.getPreviewableOutputsFromJobDetail).toHaveBeenCalledWith(
jobDetail
)

View File

@@ -19,6 +19,7 @@ type OutputAssetMapOptions = {
type ResolveOutputAssetItemsOptions = {
createdAt?: string
excludeOutputKey?: string
signal?: AbortSignal
}
type OutputKeyParts = {
@@ -90,11 +91,11 @@ function mapOutputsToAssetItems({
export async function resolveOutputAssetItems(
metadata: OutputAssetMetadata,
{ createdAt, excludeOutputKey }: ResolveOutputAssetItemsOptions = {}
{ createdAt, excludeOutputKey, signal }: ResolveOutputAssetItemsOptions = {}
): Promise<AssetItem[]> {
let outputsToDisplay = metadata.allOutputs ?? []
if (shouldLoadFullOutputs(metadata.outputCount, outputsToDisplay.length)) {
const jobDetail = await getJobDetail(metadata.jobId)
const jobDetail = await getJobDetail(metadata.jobId, signal)
const previewableOutputs = getPreviewableOutputsFromJobDetail(jobDetail)
if (previewableOutputs.length) {
outputsToDisplay = previewableOutputs

View File

@@ -227,7 +227,9 @@ describe('fetchJobs', () => {
const result = await fetchJobDetail(mockFetch, 'job1')
expect(mockFetch).toHaveBeenCalledWith('/jobs/job1')
expect(mockFetch).toHaveBeenCalledWith('/jobs/job1', {
signal: undefined
})
expect(result?.id).toBe('job1')
expect(result?.outputs).toBeDefined()
})

View File

@@ -118,11 +118,12 @@ export async function fetchQueue(
* Fetches full job details from /jobs/{job_id}
*/
export async function fetchJobDetail(
fetchApi: (url: string) => Promise<Response>,
jobId: JobId
fetchApi: (url: string, options?: RequestInit) => Promise<Response>,
jobId: JobId,
signal?: AbortSignal
): Promise<JobDetail | undefined> {
try {
const res = await fetchApi(`/jobs/${encodeURIComponent(jobId)}`)
const res = await fetchApi(`/jobs/${encodeURIComponent(jobId)}`, { signal })
if (!res.ok) {
console.warn(`Job not found for job ${jobId}`)

View File

@@ -51,7 +51,9 @@ describe('fetchJobDetail', () => {
await fetchJobDetail(mockFetchApi, 'test-job-id')
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-job-id')
expect(mockFetchApi).toHaveBeenCalledWith('/jobs/test-job-id', {
signal: undefined
})
})
it('should return job detail with workflow and outputs', async () => {

View File

@@ -1008,8 +1008,11 @@ export class ComfyApi extends EventTarget {
* @param jobId The job ID
* @returns Full job details or undefined if not found
*/
async getJobDetail(jobId: string): Promise<JobDetail | undefined> {
return fetchJobDetail(this.fetchApi.bind(this), jobId)
async getJobDetail(
jobId: string,
signal?: AbortSignal
): Promise<JobDetail | undefined> {
return fetchJobDetail(this.fetchApi.bind(this), jobId, signal)
}
/**

View File

@@ -323,7 +323,7 @@ describe('jobOutputCache', () => {
const result = await getJobDetail(jobId)
expect(result).toEqual(mockDetail)
expect(api.getJobDetail).toHaveBeenCalledWith(jobId)
expect(api.getJobDetail).toHaveBeenCalledWith(jobId, undefined)
})
it('returns cached job detail on subsequent calls', async () => {

View File

@@ -149,13 +149,14 @@ export function getPreviewableOutputsFromJobDetail(
// ===== Job Detail Caching =====
export async function getJobDetail(
jobId: string
jobId: string,
signal?: AbortSignal
): Promise<JobDetail | undefined> {
const cached = jobDetailCache.get(jobId)
if (cached) return cached
try {
const detail = await api.getJobDetail(jobId)
const detail = await api.getJobDetail(jobId, signal)
if (detail) {
jobDetailCache.set(jobId, detail)
}