Files
ComfyUI_frontend/src/renderer/extensions/linearMode/OutputHistory.vue
AustinMroz bd322314bc Display a separate indicator for pending jobs in app mode (#10382)
Previously, a pending, but not yet running job was displayed as an empty
skeleton. It is instead updated to display an animated `lucide--loader`
icon.

<img width="1544" height="1347" alt="image"
src="https://github.com/user-attachments/assets/4f82185c-97dc-44af-8ea1-a012fb992fe2"
/>

As this shifts the activeQueueItem component to display even when only
the single pending item is in the queue, the count badge is also update
to not display when this is the case.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10382-Display-a-separate-indicator-for-pending-jobs-in-app-mode-32a6d73d36508189b3a9ff4b84993a98)
by [Unito](https://www.unito.io)
2026-03-23 09:09:09 -07:00

372 lines
10 KiB
Vue

<script setup lang="ts">
import {
useEventListener,
useInfiniteScroll,
useResizeObserver
} from '@vueuse/core'
import { storeToRefs } from 'pinia'
import type { ComponentPublicInstance } from 'vue'
import {
computed,
nextTick,
ref,
toValue,
useTemplateRef,
watch,
watchEffect
} from 'vue'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type {
OutputSelection,
SelectionValue
} from '@/renderer/extensions/linearMode/linearModeTypes'
import OutputPreviewItem from '@/renderer/extensions/linearMode/OutputPreviewItem.vue'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs, selectFirstHistory, mayBeActiveWorkflowPending } =
useOutputHistory()
const { hasOutputs } = storeToRefs(useAppModeStore())
const queueStore = useQueueStore()
const store = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const emit = defineEmits<{
updateSelection: [selection: OutputSelection]
}>()
const queueCount = computed(
() => queueStore.runningTasks.length + queueStore.pendingTasks.length
)
const itemClass = cn(
'shrink-0 cursor-pointer rounded-lg border-2 border-transparent p-1 outline-none',
'relative data-[state=checked]:border-interface-panel-job-progress-border'
)
const hasActiveContent = computed(
() => store.activeWorkflowInProgressItems.length > 0
)
const visibleHistory = computed(() =>
outputs.media.value.filter((a) => allOutputs(a).length > 0)
)
const selectableItems = computed(() => {
const items: SelectionValue[] = []
if (mayBeActiveWorkflowPending.value) {
items.push({ id: 'slot:pending', kind: 'inProgress', itemId: 'pending' })
}
for (const item of store.activeWorkflowInProgressItems) {
items.push({
id: `slot:${item.id}`,
kind: 'inProgress',
itemId: item.id
})
}
for (const asset of outputs.media.value) {
const outs = allOutputs(asset)
for (let k = 0; k < outs.length; k++) {
items.push({
id: `history:${asset.id}:${k}`,
kind: 'history',
assetId: asset.id,
key: k
})
}
}
return items
})
const selectionMap = computed(
() => new Map(selectableItems.value.map((v) => [v.id, v]))
)
const selectedValue = computed(() => {
if (!store.selectedId) return undefined
return selectionMap.value.get(store.selectedId)
})
function itemAttrs(id: string) {
const selected = store.selectedId === id
return {
'data-state': selected ? 'checked' : 'unchecked',
tabindex: selected ? 0 : -1
}
}
const selectedItemEl = ref<Element | null>(null)
function selectedRef(id: string) {
return store.selectedId === id
? (el: Element | ComponentPublicInstance | null) => {
selectedItemEl.value = el instanceof Element ? el : null
}
: undefined
}
function doEmit() {
const sel = selectedValue.value
if (!sel) {
emit('updateSelection', { canShowPreview: true })
return
}
if (sel.kind === 'inProgress') {
const item = store.activeWorkflowInProgressItems.find(
(i) => i.id === sel.itemId
)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: true, showSkeleton: true })
} else if (item.state === 'latent') {
emit('updateSelection', {
canShowPreview: true,
latentPreviewUrl: item.latentPreviewUrl
})
} else {
emit('updateSelection', {
output: item.output,
canShowPreview: true
})
}
return
}
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
const output = asset ? allOutputs(asset)[sel.key] : undefined
const isFirst = outputs.media.value[0]?.id === sel.assetId
emit('updateSelection', {
asset,
output,
canShowPreview: isFirst
})
}
watchEffect(doEmit)
// On load or workflow tab switch, select the most recent item.
// Prefer in-progress items for this workflow, then history, skipping
// the global pending slot which may belong to another workflow.
watch(
() => workflowStore.activeWorkflow?.path,
(path) => {
if (!path) return
const inProgress = store.activeWorkflowInProgressItems
if (inProgress.length > 0) {
store.selectAsLatest(`slot:${inProgress[0].id}`)
} else if (hasOutputs.value) {
selectFirstHistory()
} else {
store.selectAsLatest(null)
}
},
{ immediate: true }
)
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (
newAssets.length === oldAssets.length ||
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (store.selectedId?.startsWith('slot:')) return
const sv = store.selectedId
? selectionMap.value.get(store.selectedId)
: undefined
if (!sv || sv.kind !== 'history') {
if (hasOutputs.value) selectFirstHistory()
return
}
const wasFirst = sv.assetId === oldAssets[0]?.id
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
if (hasOutputs.value) selectFirstHistory()
}
}
)
const outputsRef = useTemplateRef('outputsRef')
// Track scrollWidth so we can compensate when items are prepended on the left.
let lastScrollWidth = 0
useResizeObserver(outputsRef, () => {
lastScrollWidth = outputsRef.value?.scrollWidth ?? 0
})
watch(
() => visibleHistory.value[0]?.id,
() => {
const el = outputsRef.value
if (!el || el.scrollLeft === 0) {
lastScrollWidth = el?.scrollWidth ?? 0
return
}
nextTick(() => {
const delta = el.scrollWidth - lastScrollWidth
if (delta !== 0) el.scrollLeft += delta
lastScrollWidth = el.scrollWidth
})
}
)
useInfiniteScroll(outputsRef, outputs.loadMore, {
canLoadMore: () => outputs.hasMore.value
})
function navigateToAdjacent(direction: 1 | -1) {
const items = selectableItems.value
if (items.length === 0) return
const currentId = store.selectedId
const idx = currentId ? items.findIndex((i) => i.id === currentId) : -1
const nextIdx =
idx === -1 ? 0 : Math.max(0, Math.min(items.length - 1, idx + direction))
store.select(items[nextIdx].id)
nextTick(() => {
selectedItemEl.value?.scrollIntoView({
block: 'nearest',
inline: 'nearest'
})
})
}
const pointer = new CanvasPointer(document.body)
let scrollOffset = 0
useEventListener(
document.body,
'wheel',
function (e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
if (!pointer.isTrackpadGesture(e)) {
if (e.deltaY > 0) navigateToAdjacent(1)
else navigateToAdjacent(-1)
return
}
scrollOffset += e.deltaY
while (scrollOffset >= 60) {
scrollOffset -= 60
navigateToAdjacent(1)
}
while (scrollOffset <= -60) {
scrollOffset += 60
navigateToAdjacent(-1)
}
},
{ capture: true, passive: false }
)
useEventListener(
outputsRef,
'wheel',
function (e: WheelEvent) {
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
e.preventDefault()
if (outputsRef.value) outputsRef.value.scrollLeft += e.deltaY
},
{ passive: false }
)
const keyHandlers: Record<string, 1 | -1> = {
ArrowUp: -1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowRight: 1
}
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
if (
!(e.key in keyHandlers) ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement
)
return
e.preventDefault()
e.stopPropagation()
navigateToAdjacent(keyHandlers[e.key])
})
</script>
<template>
<div
role="group"
class="flex h-21 min-w-0 items-start justify-center px-4 py-3 pb-4"
>
<div
v-if="queueCount > 0 || hasActiveContent"
class="flex h-15 shrink-0 items-start gap-0.5"
>
<OutputHistoryActiveQueueItem
v-if="queueCount > 1 || queueStore.pendingTasks.length"
class="mr-3"
:queue-count="queueCount"
/>
<div
v-if="mayBeActiveWorkflowPending"
:ref="selectedRef('slot:pending')"
v-bind="itemAttrs('slot:pending')"
:class="itemClass"
@click="store.select('slot:pending')"
>
<OutputPreviewItem />
</div>
<div
v-for="item in store.activeWorkflowInProgressItems"
:key="`${item.id}-${item.state}`"
:ref="selectedRef(`slot:${item.id}`)"
v-bind="itemAttrs(`slot:${item.id}`)"
:class="itemClass"
@click="store.select(`slot:${item.id}`)"
>
<OutputPreviewItem
v-if="item.state !== 'image' || !item.output"
:latent-preview="item.latentPreviewUrl"
/>
<OutputHistoryItem v-else :output="item.output" />
</div>
<div
v-if="hasActiveContent && visibleHistory.length > 0"
class="mx-4 h-12 shrink-0 border-l border-border-default"
/>
</div>
<article
ref="outputsRef"
data-testid="linear-outputs"
class="min-w-0 overflow-x-auto overflow-y-clip"
>
<div class="flex h-15 w-fit items-start gap-0.5">
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
<div
v-if="aIdx > 0"
class="mx-4 h-12 shrink-0 border-l border-border-default"
/>
<div
v-for="(output, key) in toValue(allOutputs(asset))"
:key
:ref="selectedRef(`history:${asset.id}:${key}`)"
v-bind="itemAttrs(`history:${asset.id}:${key}`)"
:class="itemClass"
@click="store.select(`history:${asset.id}:${key}`)"
>
<OutputHistoryItem :output="output" />
</div>
</template>
</div>
</article>
</div>
</template>