fix: separate inspectable outputs from surfaced outputs

This commit is contained in:
Benjamin Lu
2026-04-07 15:02:35 -07:00
parent 5699d188e2
commit f78b42a56a
12 changed files with 221 additions and 58 deletions

View File

@@ -257,6 +257,10 @@ const focusAssetInSidebar = async (item: JobListItem) => {
const inspectJobAsset = wrapWithErrorHandlingAsync(
async (item: JobListItem) => {
if (!item.taskRef?.inspectableOutput) {
return
}
await openResultGallery(item)
await focusAssetInSidebar(item)
}

View File

@@ -74,11 +74,13 @@ type TestPreviewOutput = {
url: string
isImage: boolean
isVideo: boolean
is3D: boolean
}
type TestTaskRef = {
workflowId?: string
previewOutput?: TestPreviewOutput
inspectableOutput?: TestPreviewOutput
}
type TestJobListItem = Omit<JobListItem, 'taskRef'> & {
@@ -97,14 +99,24 @@ const createPreviewOutput = (
return {
url,
isImage: mediaType === 'images',
isVideo: mediaType === 'video'
isVideo: mediaType === 'video',
is3D: mediaType === 'model'
}
}
const createTaskRef = (preview?: TestPreviewOutput): TestTaskRef => ({
workflowId: 'workflow-1',
...(preview && { previewOutput: preview })
})
const createTaskRef = (
preview?: TestPreviewOutput,
inspectable?: TestPreviewOutput | null
): TestTaskRef => {
const resolvedInspectable =
inspectable === undefined ? preview : (inspectable ?? undefined)
return {
workflowId: 'workflow-1',
...(preview && { previewOutput: preview }),
...(resolvedInspectable && { inspectableOutput: resolvedInspectable })
}
}
const buildJob = (
overrides: Partial<TestJobListItem> = {}
@@ -285,12 +297,13 @@ describe('JobAssetsList', () => {
})
it('emits viewItem on icon click for completed PLY jobs without preview tile', async () => {
const previewOutput = createPreviewOutput('job-1.ply', 'model')
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(createResultItem('job-1.ply', 'model'))
taskRef: createTaskRef(previewOutput)
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList([job], { onViewItem })
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
@@ -298,6 +311,21 @@ describe('JobAssetsList', () => {
expect(onViewItem).toHaveBeenCalledWith(job)
})
it('does not emit viewItem on icon click for completed USDZ jobs without inspectable output', async () => {
const previewOutput = createPreviewOutput('job-1.usdz', 'model')
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef(previewOutput, null)
})
const onViewItem = vi.fn()
const { container, user } = renderJobAssetsList({ jobs: [job], onViewItem })
const icon = container.querySelector('.assets-list-item-stub i')!
await user.click(icon)
expect(onViewItem).not.toHaveBeenCalled()
})
it('does not emit viewItem on double-click for non-completed jobs', async () => {
const job = buildJob({
state: 'running',
@@ -312,21 +340,18 @@ describe('JobAssetsList', () => {
expect(onViewItem).not.toHaveBeenCalled()
})
it('emits viewItem from the View button for completed jobs without preview output', async () => {
it('does not show the View button for completed jobs without inspectable output', async () => {
const job = buildJob({
iconImageUrl: undefined,
taskRef: createTaskRef()
taskRef: createTaskRef(undefined, null)
})
const onViewItem = vi.fn()
const { container } = renderJobAssetsList({ jobs: [job], onViewItem })
const { container } = renderJobAssetsList({ jobs: [job] })
const jobRow = container.querySelector(`[data-job-id="${job.id}"]`)!
await fireEvent.mouseEnter(jobRow)
await fireEvent.click(screen.getByText('menuLabels.View'))
await nextTick()
expect(onViewItem).toHaveBeenCalledWith(job)
expect(screen.queryByText('menuLabels.View')).toBeNull()
})
it('shows and hides the job details popover with hover delays', async () => {

View File

@@ -67,7 +67,7 @@
<i class="icon-[lucide--trash-2] size-4" />
</Button>
<Button
v-else-if="row.job.state === 'completed'"
v-else-if="hasInspectableCompletedAsset(row.job)"
variant="textonly"
size="sm"
@click.stop="emitCompletedViewItem(row.job)"
@@ -284,6 +284,10 @@ function getPreviewOutput(job: JobListItem) {
return job.taskRef?.previewOutput
}
function getInspectableOutput(job: JobListItem) {
return job.taskRef?.inspectableOutput
}
function getJobPreviewUrl(job: JobListItem) {
const preview = getPreviewOutput(job)
if (preview?.isImage || preview?.isVideo) {
@@ -296,12 +300,12 @@ function isVideoPreviewJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)?.isVideo
}
function isPreviewableCompletedJob(job: JobListItem) {
return job.state === 'completed' && !!getPreviewOutput(job)
function hasInspectableCompletedAsset(job: JobListItem) {
return job.state === 'completed' && !!getInspectableOutput(job)
}
function emitViewItem(job: JobListItem) {
if (isPreviewableCompletedJob(job)) {
if (hasInspectableCompletedAsset(job)) {
resetActiveDetails()
emit('viewItem', job)
}

View File

@@ -160,15 +160,15 @@ const {
} = useResultGallery(() => filteredTasks.value)
const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
const previewOutput = item.taskRef?.previewOutput
const inspectableOutput = item.taskRef?.inspectableOutput
if (previewOutput?.is3D) {
if (inspectableOutput?.is3D) {
dialogStore.showDialog({
key: 'asset-3d-viewer',
title: item.title,
component: Load3dViewerContent,
props: {
modelUrl: previewOutput.url || ''
modelUrl: inspectableOutput.url || ''
},
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
@@ -178,6 +178,10 @@ const onViewItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
return
}
if (!inspectableOutput) {
return
}
await openResultGallery(item)
})

View File

@@ -725,7 +725,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
taskRef: { previewOutput: {}, inspectableOutput: {} }
})
)
@@ -763,7 +763,7 @@ describe('useJobMenu', () => {
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: { previewOutput: {} }
taskRef: { previewOutput: {}, inspectableOutput: {} }
})
)
@@ -792,6 +792,35 @@ describe('useJobMenu', () => {
)
})
it('disables inspect when asset is surfaced but not inspectable', async () => {
const { jobMenuEntries } = mountJobMenu(vi.fn())
setCurrentItem(
createJobItem({
state: 'completed',
taskRef: {
previewOutput: {
filename: 'mesh.usdz',
subfolder: 'models',
type: 'output',
url: 'https://asset'
}
}
})
)
await nextTick()
expect(
findActionEntry(jobMenuEntries.value, 'inspect-asset')?.disabled
).toBe(true)
expect(findActionEntry(jobMenuEntries.value, 'download')?.disabled).toBe(
false
)
expect(jobMenuEntries.value.some((entry) => entry.key === 'delete')).toBe(
true
)
})
it('returns failed menu entries with error actions', async () => {
const { jobMenuEntries } = mountJobMenu()
setCurrentItem(

View File

@@ -242,13 +242,14 @@ export function useJobMenu(
const state = item?.state
if (!state) return []
const hasPreviewAsset = !!item?.taskRef?.previewOutput
const hasInspectableAsset = !!item?.taskRef?.inspectableOutput
if (state === 'completed') {
return [
{
key: 'inspect-asset',
label: st('queue.jobMenu.inspectAsset', 'Inspect asset'),
icon: 'icon-[lucide--zoom-in]',
disabled: !hasPreviewAsset || !onInspectAsset,
disabled: !hasInspectableAsset || !onInspectAsset,
onClick: onInspectAsset
? () => {
const item = currentMenuItem()

View File

@@ -8,7 +8,13 @@ import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
const createResultItem = (
url: string,
supportsPreview = true
{
supportsPreview = true,
supportsInspection = supportsPreview
}: {
supportsPreview?: boolean
supportsInspection?: boolean
} = {}
): ResultItemImpl => {
const item = new ResultItemImpl({
filename: url,
@@ -20,6 +26,9 @@ const createResultItem = (
// Override url getter for test matching
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'supportsInspection', {
get: () => supportsInspection
})
return item
}
@@ -63,12 +72,15 @@ describe('useResultGallery', () => {
setActivePinia(createPinia())
})
it('collects only previewable outputs and preserves their order', async () => {
it('collects only inspectable outputs and preserves their order', async () => {
const previewable = [createResultItem('p-1'), createResultItem('p-2')]
const nonPreviewable = createResultItem('skip-me', false)
const nonInspectable = createResultItem('skip-me', {
supportsPreview: true,
supportsInspection: false
})
const tasks = [
createTask(previewable[0]),
createTask(nonPreviewable),
createTask(nonInspectable),
createTask(previewable[1]),
createTask()
]
@@ -83,7 +95,7 @@ describe('useResultGallery', () => {
expect(galleryActiveIndex.value).toBe(0)
})
it('does not change state when there are no previewable tasks', async () => {
it('does not change state when there are no inspectable tasks', async () => {
const { galleryItems, galleryActiveIndex, onViewItem } = useResultGallery(
() => []
)

View File

@@ -1,7 +1,10 @@
import { ref, shallowRef } from 'vue'
import type { JobListItem } from '@/composables/queue/useJobList'
import { findActiveIndex, getOutputsForTask } from '@/services/jobOutputCache'
import {
findActiveIndex,
getInspectableOutputsForTask
} from '@/services/jobOutputCache'
import type { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
/**
@@ -17,7 +20,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const targetTask = item.taskRef
const targetOutputs = targetTask
? await getOutputsForTask(targetTask)
? await getInspectableOutputsForTask(targetTask)
: null
// Request was superseded by a newer one
@@ -27,7 +30,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
const items = targetOutputs?.length
? targetOutputs
: tasks
.map((t) => t.previewOutput)
.map((t) => t.inspectableOutput)
.filter((o): o is ResultItemImpl => !!o)
if (!items.length) return
@@ -35,7 +38,7 @@ export function useResultGallery(getFilteredTasks: () => TaskItemImpl[]) {
galleryItems.value = items
galleryActiveIndex.value = findActiveIndex(
items,
item.taskRef?.previewOutput?.url
item.taskRef?.inspectableOutput?.url
)
}

View File

@@ -11,6 +11,7 @@ import {
findActiveIndex,
getJobDetail,
getJobWorkflow,
getInspectableOutputsForTask,
getOutputsForTask
} from '@/services/jobOutputCache'
import { ResultItemImpl, TaskItemImpl } from '@/stores/queueStore'
@@ -29,7 +30,16 @@ vi.mock('@/scripts/api', () => ({
}
}))
function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
function createResultItem(
url: string,
{
supportsPreview = true,
supportsInspection = supportsPreview
}: {
supportsPreview?: boolean
supportsInspection?: boolean
} = {}
): ResultItemImpl {
const item = new ResultItemImpl({
filename: url,
subfolder: '',
@@ -39,6 +49,9 @@ function createResultItem(url: string, supportsPreview = true): ResultItemImpl {
})
Object.defineProperty(item, 'url', { get: () => url })
Object.defineProperty(item, 'supportsPreview', { get: () => supportsPreview })
Object.defineProperty(item, 'supportsInspection', {
get: () => supportsInspection
})
return item
}
@@ -101,16 +114,30 @@ describe('jobOutputCache', () => {
})
})
describe('getOutputsForTask', () => {
it('returns previewable outputs directly when no lazy load needed', async () => {
describe('getInspectableOutputsForTask', () => {
it('returns inspectable outputs directly when no lazy load needed', async () => {
const outputs = [createResultItem('p-1'), createResultItem('p-2')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual(outputs)
})
it('filters out surfaced outputs that are not inspectable', async () => {
const outputs = [
createResultItem('mesh.usdz', {
supportsPreview: true,
supportsInspection: false
})
]
const task = createTask(undefined, outputs, 1)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual([])
})
it('lazy loads when outputsCount > 1', async () => {
const previewOutput = createResultItem('preview')
const fullOutputs = [
@@ -123,7 +150,7 @@ describe('jobOutputCache', () => {
const loadedTask = new TaskItemImpl(job, {}, fullOutputs)
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual(fullOutputs)
expect(task.loadFullOutputs).toHaveBeenCalled()
@@ -138,11 +165,11 @@ describe('jobOutputCache', () => {
task.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask)
// First call should load
await getOutputsForTask(task)
await getInspectableOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
// Second call should use cache
await getOutputsForTask(task)
await getInspectableOutputsForTask(task)
expect(task.loadFullOutputs).toHaveBeenCalledTimes(1)
})
@@ -155,7 +182,7 @@ describe('jobOutputCache', () => {
.fn()
.mockRejectedValue(new Error('Network error'))
const result = await getOutputsForTask(task)
const result = await getInspectableOutputsForTask(task)
expect(result).toEqual([previewOutput])
})
@@ -184,8 +211,8 @@ describe('jobOutputCache', () => {
task2.loadFullOutputs = vi.fn().mockResolvedValue(loadedTask2)
// Start task1, then immediately start task2
const promise1 = getOutputsForTask(task1)
const promise2 = getOutputsForTask(task2)
const promise1 = getInspectableOutputsForTask(task1)
const promise2 = getInspectableOutputsForTask(task2)
const [result1, result2] = await Promise.all([promise1, promise2])
@@ -195,6 +222,17 @@ describe('jobOutputCache', () => {
})
})
describe('getOutputsForTask', () => {
it('aliases getInspectableOutputsForTask', async () => {
const outputs = [createResultItem('p-1')]
const task = createTask(undefined, outputs, 1)
const result = await getOutputsForTask(task)
expect(result).toEqual(outputs)
})
})
describe('getPreviewableOutputsFromJobDetail', () => {
it('returns empty array when job detail or outputs are missing', async () => {
const { getPreviewableOutputsFromJobDetail } =

View File

@@ -40,10 +40,10 @@ export function findActiveIndex(
}
/**
* Gets previewable outputs for a task, with lazy loading, caching, and request deduping.
* Gets inspectable outputs for a task, with lazy loading, caching, and request deduping.
* Returns null if a newer request superseded this one while loading.
*/
export async function getOutputsForTask(
export async function getInspectableOutputsForTask(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
const requestId = String(task.jobId)
@@ -53,12 +53,12 @@ export async function getOutputsForTask(
const needsLazyLoad = outputsCount > 1
if (!needsLazyLoad) {
return [...task.previewableOutputs]
return [...task.inspectableOutputs]
}
const cached = taskCache.get(requestId)
if (cached) {
return [...cached.previewableOutputs]
return [...cached.inspectableOutputs]
}
try {
@@ -70,13 +70,22 @@ export async function getOutputsForTask(
}
taskCache.set(requestId, loadedTask)
return [...loadedTask.previewableOutputs]
return [...loadedTask.inspectableOutputs]
} catch (error) {
console.warn('Failed to load full outputs, using preview:', error)
return [...task.previewableOutputs]
console.warn(
'Failed to load full outputs, using inspectable outputs:',
error
)
return [...task.inspectableOutputs]
}
}
export async function getOutputsForTask(
task: TaskItemImpl
): Promise<ResultItemImpl[] | null> {
return getInspectableOutputsForTask(task)
}
function getPreviewableOutputs(outputs?: TaskOutput): ResultItemImpl[] {
if (!outputs) return []
return ResultItemImpl.filterPreviewable(parseTaskOutput(outputs))

View File

@@ -223,12 +223,15 @@ describe('TaskItemImpl', () => {
const output = task.previewOutput
expect(task.previewableOutputs).toHaveLength(1)
expect(task.inspectableOutputs).toHaveLength(1)
expect(output?.filename).toBe('mesh-output.ply')
expect(task.inspectableOutput?.filename).toBe('mesh-output.ply')
expect(output?.is3D).toBe(true)
expect(output?.supportsPreview).toBe(true)
expect(output?.supportsInspection).toBe(true)
})
it('should not treat USDZ output as previewable 3D media', () => {
it('should surface USDZ output without treating it as inspectable 3D media', () => {
const job: JobListItem = {
...createHistoryJob(0, 'usdz-job'),
preview_output: {
@@ -242,8 +245,13 @@ describe('TaskItemImpl', () => {
const task = new TaskItemImpl(job)
expect(task.previewableOutputs).toHaveLength(0)
expect(task.previewOutput).toBeUndefined()
expect(task.previewableOutputs).toHaveLength(1)
expect(task.previewOutput?.filename).toBe('mesh-output.usdz')
expect(task.previewOutput?.is3D).toBe(true)
expect(task.previewOutput?.supportsPreview).toBe(true)
expect(task.previewOutput?.supportsInspection).toBe(false)
expect(task.inspectableOutputs).toHaveLength(0)
expect(task.inspectableOutput).toBeUndefined()
})
describe('error extraction getters', () => {

View File

@@ -223,15 +223,22 @@ export class ResultItemImpl {
}
get is3D(): boolean {
return (
getMediaTypeFromFilename(this.filename) === '3D' &&
isPreviewableMediaFilename(this.filename)
)
return getMediaTypeFromFilename(this.filename) === '3D'
}
get isText(): boolean {
return this.mediaType === 'text'
}
get supportsInspection(): boolean {
return (
this.isImage ||
this.isVideo ||
this.isAudio ||
(this.is3D && isPreviewableMediaFilename(this.filename))
)
}
get supportsPreview(): boolean {
return (
this.isImage || this.isVideo || this.isAudio || this.is3D || this.isText
@@ -244,6 +251,12 @@ export class ResultItemImpl {
return outputs.filter((o) => o.supportsPreview)
}
static filterInspectable(
outputs: readonly ResultItemImpl[]
): ResultItemImpl[] {
return outputs.filter((o) => o.supportsInspection)
}
static findByUrl(items: readonly ResultItemImpl[], url?: string): number {
if (!url) return 0
const idx = items.findIndex((o) => o.url === url)
@@ -284,11 +297,16 @@ export class TaskItemImpl {
return parseTaskOutput(this.outputs)
}
/** All outputs that support preview (images, videos, audio, 3D, text) */
/** All surfaced outputs (images, videos, audio, 3D, text) */
get previewableOutputs(): readonly ResultItemImpl[] {
return ResultItemImpl.filterPreviewable(this.flatOutputs)
}
/** Outputs the UI can inspect directly in the viewer/lightbox. */
get inspectableOutputs(): readonly ResultItemImpl[] {
return ResultItemImpl.filterInspectable(this.flatOutputs)
}
get previewOutput(): ResultItemImpl | undefined {
const previewable = this.previewableOutputs
// Prefer the last saved media file (most recent result) over temp previews
@@ -298,6 +316,14 @@ export class TaskItemImpl {
)
}
get inspectableOutput(): ResultItemImpl | undefined {
const inspectable = this.inspectableOutputs
return (
inspectable.findLast((output) => output.type === 'output') ??
inspectable.at(-1)
)
}
// Derive taskType from job status
get taskType(): TaskType {
switch (this.job.status) {