Files
ComfyUI_frontend/src/renderer/extensions/linearMode/OutputHistory.vue
AustinMroz 3bfd62b9fc Linear: progressbar, tooltips, and output fixes (#8250)
- Fixes only the first output being displayed in linear mode after the
jobs migration
- Fixes selected output no longer scrolling into view in history
- Adds a progress bar indicator on running job
<img width="113" height="102" alt="image"
src="https://github.com/user-attachments/assets/ca684dbe-12c8-44aa-98f0-2985c0159156"
/>
- Moves linear toggle button to v-tooltip
- Fixes placeholder sometimes continuing to display after a new output.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8250-Linear-progressbar-tooltips-and-output-fixes-2f06d73d365081ca9fa3ebf0e2516487)
by [Unito](https://www.unito.io)
2026-01-23 21:08:31 -08:00

378 lines
11 KiB
Vue

<script setup lang="ts">
import {
useAsyncState,
useEventListener,
useInfiniteScroll,
useScroll
} from '@vueuse/core'
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
import type { MaybeRef } from 'vue'
import ModeToggle from '@/components/sidebar/ModeToggle.vue'
import SidebarIcon from '@/components/sidebar/SidebarIcon.vue'
import SidebarTemplatesButton from '@/components/sidebar/SidebarTemplatesButton.vue'
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
import Button from '@/components/ui/button/Button.vue'
import { useProgressBarBackground } from '@/composables/useProgressBarBackground'
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { getJobDetail } from '@/services/jobOutputCache'
import { useQueueStore, ResultItemImpl } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { cn } from '@/utils/tailwindUtil'
const displayWorkflows = ref(false)
const outputs = useMediaAssets('output')
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
progressPercentStyle
} = useProgressBarBackground()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
const settingStore = useSettingStore()
const workflowTab = useWorkspaceStore()
.getSidebarTabs()
.find((w) => w.id === 'workflows')
void outputs.fetchMediaList()
defineProps<{
scrollResetButtonTo?: string | HTMLElement
mobile?: boolean
}>()
const emit = defineEmits<{
updateSelection: [
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
]
}>()
defineExpose({ onWheel })
const selectedIndex = ref<[number, number]>([-1, 0])
function doEmit() {
const [index] = selectedIndex.value
emit('updateSelection', [
outputs.media.value[index],
selectedOutput.value,
selectedIndex.value[0] <= 0
])
}
const outputsRef = useTemplateRef('outputsRef')
const { reset: resetInfiniteScroll } = useInfiniteScroll(
outputsRef,
outputs.loadMore,
{ canLoadMore: () => outputs.hasMore.value }
)
function resetOutputsScroll() {
//TODO need to also prune outputs entries?
resetInfiniteScroll()
outputsRef.value?.scrollTo(0, 0)
}
const { y: outputScrollState } = useScroll(outputsRef)
watch(selectedIndex, () => {
const [index, key] = selectedIndex.value
if (!outputsRef.value) return
const outputElement = outputsRef.value?.querySelectorAll(
`[data-output-index="${index}"]`
)?.[key]
if (!outputElement) return
//container: 'nearest' is nice, but bleeding edge and chrome only
outputElement.scrollIntoView({ block: 'nearest' })
})
function outputCount(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
return user_metadata?.outputCount ?? 0
}
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
function flattenNodeOutput([nodeId, nodeOutput]: [
string | number,
NodeExecutionOutput
]): ResultItemImpl[] {
const knownOutputs: Record<string, ResultItem[]> = {}
if (nodeOutput.audio) knownOutputs.audio = nodeOutput.audio
if (nodeOutput.images) knownOutputs.images = nodeOutput.images
if (nodeOutput.video) knownOutputs.video = nodeOutput.video
if (nodeOutput.gifs) knownOutputs.gifs = nodeOutput.gifs as ResultItem[]
if (nodeOutput['3d']) knownOutputs['3d'] = nodeOutput['3d'] as ResultItem[]
return Object.entries(knownOutputs).flatMap(([mediaType, outputs]) =>
outputs.map(
(output) => new ResultItemImpl({ ...output, mediaType, nodeId })
)
)
}
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
if (!user_metadata) return []
if (
user_metadata.allOutputs &&
user_metadata.outputCount &&
user_metadata.outputCount < user_metadata.allOutputs.length
)
return user_metadata.allOutputs
const outputRef = useAsyncState(
getJobDetail(user_metadata.promptId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
return Object.entries(jobDetail.outputs).flatMap(flattenNodeOutput)
}),
[]
).state
outputsCache[item!.id] = outputRef
return outputRef
}
const selectedOutput = computed(() => {
const [index, key] = selectedIndex.value
if (index < 0) return undefined
return toValue(allOutputs(outputs.media.value[index]))[key]
})
watch([selectedIndex, selectedOutput], doEmit)
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
if (newAssets.length === oldAssets.length || oldAssets.length === 0) return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
return
}
const oldId = toRaw(oldAssets[selectedIndex.value[0]]?.id)
const newIndex = toRaw(newAssets).findIndex((asset) => asset?.id === oldId)
if (newIndex === -1) selectedIndex.value = [0, 0]
else selectedIndex.value = [newIndex, selectedIndex.value[1]]
}
)
function gotoNextOutput() {
const [index, key] = selectedIndex.value
if (index < 0 || key < 0) {
selectedIndex.value = [0, 0]
return
}
if (key + 1 < outputCount(outputs.media.value[index])) {
selectedIndex.value = [index, key + 1]
return
}
if (outputs.media.value[index + 1]) {
selectedIndex.value = [index + 1, 0]
}
//do nothing, no next output
}
function gotoPreviousOutput() {
const [index, key] = selectedIndex.value
if (key > 0) {
selectedIndex.value = [index, key - 1]
return
}
if (index > 0) {
const len = outputCount(outputs.media.value[index - 1])
selectedIndex.value = [index - 1, len - 1]
return
}
selectedIndex.value = [0, 0]
}
let pointer = new CanvasPointer(document.body)
let scrollOffset = 0
function onWheel(e: WheelEvent) {
if (!e.ctrlKey && !e.metaKey) return
e.preventDefault()
e.stopPropagation()
if (!pointer.isTrackpadGesture(e)) {
if (e.deltaY > 0) gotoNextOutput()
else gotoPreviousOutput()
return
}
scrollOffset += e.deltaY
while (scrollOffset >= 60) {
scrollOffset -= 60
gotoNextOutput()
}
while (scrollOffset <= -60) {
scrollOffset += 60
gotoPreviousOutput()
}
}
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
if (
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
e.target instanceof HTMLTextAreaElement ||
e.target instanceof HTMLInputElement
)
return
e.preventDefault()
e.stopPropagation()
if (e.key === 'ArrowDown') gotoNextOutput()
else gotoPreviousOutput()
})
</script>
<template>
<div
:class="
cn(
'min-w-38 flex bg-comfy-menu-bg md:h-full border-border-subtle',
settingStore.get('Comfy.Sidebar.Location') === 'right'
? 'flex-row-reverse border-l'
: 'md:border-r'
)
"
v-bind="$attrs"
>
<div
v-if="!mobile"
class="h-full flex flex-col w-14 shrink-0 overflow-hidden items-center p-2"
>
<template v-if="workflowTab">
<SidebarIcon
:icon="workflowTab.icon"
:icon-badge="workflowTab.iconBadge"
:tooltip="workflowTab.tooltip"
:label="workflowTab.label || workflowTab.title"
:class="workflowTab.id + '-tab-button'"
:selected="displayWorkflows"
:is-small="settingStore.get('Comfy.Sidebar.Size') === 'small'"
@click="displayWorkflows = !displayWorkflows"
/>
</template>
<SidebarTemplatesButton />
<div class="flex-1" />
<ModeToggle />
</div>
<div class="border-border-subtle md:border-r" />
<WorkflowsSidebarTab v-if="displayWorkflows" class="min-w-50 grow-1" />
<article
v-else
ref="outputsRef"
data-testid="linear-outputs"
class="h-24 md:h-full min-w-24 grow-1 p-3 overflow-x-auto overflow-y-clip md:overflow-y-auto md:overflow-x-clip md:border-r-1 border-node-component-border flex md:flex-col items-center contain-size"
>
<section
v-if="
queueStore.runningTasks.length > 0 ||
queueStore.pendingTasks.length > 0
"
data-testid="linear-job"
class="py-3 not-md:h-24 md:w-full aspect-square px-1 relative"
>
<i
v-if="queueStore.runningTasks.length > 0"
class="icon-[lucide--loader-circle] size-full animate-spin"
/>
<i v-else class="icon-[lucide--ellipsis] size-full animate-pulse" />
<div
v-if="
queueStore.runningTasks.length + queueStore.pendingTasks.length > 1
"
class="absolute top-0 right-0 p-1 min-w-5 h-5 flex justify-center items-center rounded-full bg-primary-background text-text-primary"
v-text="
queueStore.runningTasks.length + queueStore.pendingTasks.length
"
/>
<div class="absolute -bottom-1 w-full h-3 rounded-sm overflow-clip">
<div :class="progressBarContainerClass">
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(totalPercent)"
/>
<div
:class="progressBarSecondaryClass"
:style="progressPercentStyle(currentNodePercent)"
/>
</div>
</div>
</section>
<template v-for="(item, index) in outputs.media.value" :key="index">
<div
class="border-border-subtle not-md:border-l md:border-t first:border-none not-md:h-21 md:w-full m-3"
/>
<template v-for="(output, key) in toValue(allOutputs(item))" :key>
<img
v-if="getMediaType(output) === 'images'"
:class="
cn(
'p-1 rounded-lg aspect-square object-cover not-md:h-20 md:w-full',
index === selectedIndex[0] &&
key === selectedIndex[1] &&
'border-2'
)
"
:data-output-index="index"
:src="output.url"
@click="selectedIndex = [index, key]"
/>
<div
v-else
:class="
cn(
'p-1 rounded-lg aspect-square w-full',
index === selectedIndex[0] &&
key === selectedIndex[1] &&
'border-2'
)
"
:data-output-index="index"
@click="selectedIndex = [index, key]"
>
<i
:class="
cn(mediaTypes[getMediaType(output)]?.iconClass, 'size-full')
"
/>
</div>
</template>
</template>
</article>
</div>
<Teleport
v-if="outputScrollState && scrollResetButtonTo"
:to="scrollResetButtonTo"
>
<Button
:class="
cn(
'p-3 size-10 bg-base-foreground',
settingStore.get('Comfy.Sidebar.Location') === 'left'
? 'left-4'
: 'right-4'
)
"
@click="resetOutputsScroll"
>
<i class="icon-[lucide--arrow-up] size-4 bg-base-background" />
</Button>
</Teleport>
</template>