App Mode - Progress/outputs - 5 (#9028)

## Summary

Adds new store for tracking outputs lin linear mode and reworks outputs
to display the following: skeleton -> latent preview -> node output ->
job result.

## Changes

- **What**: 
- New store for wrangling various events into a usable list of live
outputs
- Replace manual list with reka-ui list box
- Extract various composables

## Review Focus
Getting all the events and stores aligned to happen consistently and in
the correct order was a challenge, unifying the various sources. so
suggestions there would be good

## Screenshots (if applicable)


https://github.com/user-attachments/assets/13449780-ee48-4d9a-b3aa-51dca0a124c7

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9028-App-Mode-Progress-outputs-5-30d6d73d3650817aaa9dee622fffe426)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
pythongosssss
2026-02-23 18:43:25 +00:00
committed by GitHub
parent b29dbec916
commit f811b173c6
20 changed files with 1484 additions and 392 deletions

View File

@@ -0,0 +1,7 @@
<template>
<div class="flex-1 min-h-0 w-full flex items-center justify-center">
<div
class="aspect-square size-[min(50vw,50vh)] rounded-lg skeleton-shimmer"
/>
</div>
</template>

View File

@@ -158,7 +158,8 @@ defineExpose({ runButtonClick })
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
root-class="text-base-foreground grid-cols-[auto_96px]"
class="*:[.min-w-0]:w-24"
/>
<SubscribeToRunButton v-if="!isActiveSubscription" class="w-full mt-4" />
<div v-else class="flex mt-4 gap-2">
@@ -265,32 +266,23 @@ defineExpose({ runButtonClick })
<WidgetInputNumberInput
v-model="batchCount"
:widget="batchCountWidget"
class="*:[.min-w-0]:w-24 grid-cols-[auto_96px]!"
root-class="text-base-foreground grid-cols-[auto_96px]"
class="*:[.min-w-0]:w-24"
/>
<SubscribeToRunButton
v-if="!isActiveSubscription"
class="w-full mt-4"
/>
<div v-else class="flex mt-4 gap-2">
<Button
variant="primary"
class="grow-1"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
<Button
v-if="!executionStore.isIdle"
variant="destructive"
size="lg"
class="w-10 p-2"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
</div>
<Button
v-else
variant="primary"
class="w-full mt-4"
size="lg"
@click="runButtonClick"
>
<i class="icon-[lucide--play]" />
{{ t('menu.run') }}
</Button>
</section>
</div>
</div>

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { whenever } from '@vueuse/core'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
@@ -12,27 +11,28 @@ import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { extractWorkflowFromAsset } from '@/platform/workflow/utils/workflowExtractionUtil'
import ImagePreview from '@/renderer/extensions/linearMode/ImagePreview.vue'
import LatentPreview from '@/renderer/extensions/linearMode/LatentPreview.vue'
import LinearWelcome from '@/renderer/extensions/linearMode/LinearWelcome.vue'
import LinearArrange from '@/renderer/extensions/linearMode/LinearArrange.vue'
import OutputHistory from '@/renderer/extensions/linearMode/OutputHistory.vue'
import type { OutputSelection } from '@/renderer/extensions/linearMode/linearModeTypes'
// Lazy-loaded to avoid pulling THREE.js into the main bundle
const Preview3d = () => import('@/renderer/extensions/linearMode/Preview3d.vue')
import VideoPreview from '@/renderer/extensions/linearMode/VideoPreview.vue'
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { StatItem } from '@/renderer/extensions/linearMode/mediaTypes'
import { getMediaType } from '@/renderer/extensions/linearMode/mediaTypes'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useQueueStore } from '@/stores/queueStore'
import type { ResultItemImpl } from '@/stores/queueStore'
import { formatDuration } from '@/utils/dateTimeUtil'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
import { executeWidgetsCallback } from '@/utils/litegraphUtil'
import { useAppModeStore } from '@/stores/appModeStore'
const { t, d } = useI18n()
const { t } = useI18n()
const commandStore = useCommandStore()
const executionStore = useExecutionStore()
const mediaActions = useMediaAssetActions()
const nodeOutputStore = useNodeOutputStore()
const queueStore = useQueueStore()
const appModeStore = useAppModeStore()
const { runButtonClick } = defineProps<{
runButtonClick?: (e: Event) => void
@@ -43,43 +43,14 @@ const selectedItem = ref<AssetItem>()
const selectedOutput = ref<ResultItemImpl>()
const canShowPreview = ref(true)
const latentPreview = ref<string>()
whenever(
() => nodeOutputStore.latestPreview[0],
() => (latentPreview.value = nodeOutputStore.latestPreview[0])
)
const dateOptions = {
month: 'short',
day: 'numeric',
year: 'numeric'
} as const
const timeOptions = {
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
} as const
function formatTime(time?: string) {
if (!time) return ''
const date = new Date(time)
return `${d(date, dateOptions)} | ${d(date, timeOptions)}`
function handleSelection(sel: OutputSelection) {
selectedItem.value = sel.asset
selectedOutput.value = sel.output
canShowPreview.value = sel.canShowPreview
latentPreview.value = sel.latentPreviewUrl
}
const itemStats = computed<StatItem[]>(() => {
if (!selectedItem.value) return []
const user_metadata = getOutputAssetMetadata(selectedItem.value.user_metadata)
if (!user_metadata) return []
const { allOutputs } = user_metadata
return [
{ content: formatTime(selectedItem.value.created_at) },
{ content: formatDuration(user_metadata.executionTimeInSeconds) },
allOutputs && { content: t('g.asset', allOutputs.length) },
(selectedOutput.value && mediaTypes[getMediaType(selectedOutput.value)]) ??
{}
].filter((i) => !!i)
})
function downloadAsset(item?: AssetItem) {
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
for (const output of user_metadata?.allOutputs ?? [])
@@ -112,21 +83,11 @@ async function rerun(e: Event) {
</script>
<template>
<section
v-if="selectedItem"
v-if="selectedItem || selectedOutput || !executionStore.isIdle"
data-testid="linear-output-info"
class="flex flex-wrap gap-2 p-1 w-full md:z-10 tabular-nums justify-between text-sm"
class="flex flex-wrap gap-2 p-4 w-full md:z-10 tabular-nums justify-center text-sm"
>
<div class="flex gap-3 text-nowrap">
<div
v-for="({ content, iconClass }, index) in itemStats"
:key="index"
class="flex items-center justify-items-center gap-1 tabular-nums"
>
<i v-if="iconClass" :class="iconClass" />
{{ content }}
</div>
</div>
<div class="flex gap-3 justify-self-end">
<template v-if="selectedItem">
<Button size="md" @click="rerun">
{{ t('linearMode.rerun') }}
<i class="icon-[lucide--refresh-cw]" />
@@ -136,32 +97,44 @@ async function rerun(e: Event) {
<i class="icon-[lucide--list-restart]" />
</Button>
<div class="border-r border-border-subtle mx-1" />
<Button
size="icon"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
>
<i class="icon-[lucide--download]" />
</Button>
<Popover
:entries="[
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
command: () => downloadAsset(selectedItem!)
},
{ separator: true },
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
command: () => mediaActions.deleteAssets(selectedItem!)
}
]"
/>
</div>
</template>
<Button
v-if="selectedOutput"
size="icon"
:aria-label="t('g.download')"
@click="
() => {
if (selectedOutput?.url) downloadFile(selectedOutput.url)
}
"
>
<i class="icon-[lucide--download]" />
</Button>
<Button
v-if="!executionStore.isIdle && !selectedItem"
variant="destructive"
size="icon"
:aria-label="t('menu.interrupt')"
@click="commandStore.execute('Comfy.Interrupt')"
>
<i class="icon-[lucide--x]" />
</Button>
<Popover
v-if="selectedItem"
:entries="[
{
icon: 'icon-[lucide--download]',
label: t('linearMode.downloadAll'),
command: () => downloadAsset(selectedItem!)
},
{ separator: true },
{
icon: 'icon-[lucide--trash-2]',
label: t('queue.jobMenu.deleteAsset'),
command: () => mediaActions.deleteAssets(selectedItem!)
}
]"
/>
</section>
<ImagePreview
v-if="
@@ -191,14 +164,8 @@ async function rerun(e: Event) {
v-else-if="getMediaType(selectedOutput) === '3d'"
:model-url="selectedOutput!.url"
/>
<LatentPreview v-else-if="queueStore.runningTasks.length > 0" />
<LinearArrange v-else-if="appModeStore.mode === 'builder:arrange'" />
<LinearWelcome v-else />
<OutputHistory
@update-selection="
(event) => {
;[selectedItem, selectedOutput, canShowPreview] = event
latentPreview = undefined
}
"
/>
<OutputHistory @update-selection="handleSelection" />
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
import { useQueueProgress } from '@/composables/queue/useQueueProgress'
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
defineOptions({ inheritAttrs: false })
const {
class: className,
overallOpacity = 1,
activeOpacity = 1
} = defineProps<{
class?: string
overallOpacity?: number
activeOpacity?: number
}>()
const { totalPercent, currentNodePercent } = useQueueProgress()
const queueStore = useQueueStore()
</script>
<template>
<div
:class="
cn(
'relative h-2 bg-secondary-background transition-opacity',
queueStore.runningTasks.length === 0 && 'opacity-0',
className
)
"
>
<div
class="absolute inset-0 h-full bg-interface-panel-job-progress-primary transition-[width]"
:style="{ width: `${totalPercent}%`, opacity: overallOpacity }"
/>
<div
class="absolute inset-0 h-full bg-interface-panel-job-progress-secondary transition-[width]"
:style="{ width: `${currentNodePercent}%`, opacity: activeOpacity }"
/>
</div>
</template>

View File

@@ -1,130 +1,146 @@
<script setup lang="ts">
import { useEventListener, useInfiniteScroll } from '@vueuse/core'
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
import {
useAsyncState,
useEventListener,
useInfiniteScroll
} from '@vueuse/core'
import { computed, ref, toRaw, toValue, useTemplateRef, watch } from 'vue'
import type { MaybeRef } from 'vue'
computed,
nextTick,
toValue,
useTemplateRef,
watch,
watchEffect
} from '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 {
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 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 { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const outputs = useMediaAssets('output')
const {
progressBarContainerClass,
progressBarPrimaryClass,
progressBarSecondaryClass,
progressPercentStyle
} = useProgressBarBackground()
const { totalPercent, currentNodePercent } = useQueueProgress()
const { outputs, allOutputs } = useOutputHistory()
const queueStore = useQueueStore()
void outputs.fetchMediaList()
const store = useLinearOutputStore()
const emit = defineEmits<{
updateSelection: [
selection: [AssetItem | undefined, ResultItemImpl | undefined, boolean]
]
updateSelection: [selection: OutputSelection]
}>()
const selectedIndex = ref<[number, number]>([-1, 0])
const queueCount = computed(
() => queueStore.runningTasks.length + queueStore.pendingTasks.length
)
const listboxRef = useTemplateRef<{
highlightItem: (value: SelectionValue) => void
}>('listboxRef')
const itemClass = cn(
'shrink-0 cursor-pointer p-1 rounded-lg border-2 border-transparent outline-none',
'data-[state=checked]:border-interface-panel-job-progress-border'
)
const hasActiveContent = computed(() => store.inProgressItems.length > 0)
const visibleHistory = computed(() =>
outputs.media.value.filter((a) => toValue(allOutputs(a)).length > 0)
)
const selectableItems = computed(() => {
const items: SelectionValue[] = []
for (const item of store.inProgressItems) {
items.push({
id: `slot:${item.id}`,
kind: 'inProgress',
itemId: item.id
})
}
for (const asset of outputs.media.value) {
const outs = toValue(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 onSelectionChange(val: unknown) {
const sv = val as SelectionValue | undefined
store.select(sv?.id ?? null)
}
function doEmit() {
const [index] = selectedIndex.value
emit('updateSelection', [
outputs.media.value[index],
selectedOutput.value,
selectedIndex.value[0] <= 0
])
const sel = selectedValue.value
if (!sel) {
emit('updateSelection', { canShowPreview: true })
return
}
if (sel.kind === 'inProgress') {
const item = store.inProgressItems.find((i) => i.id === sel.itemId)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: 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 ? toValue(allOutputs(asset))[sel.key] : undefined
const isFirst = outputs.media.value[0]?.id === sel.assetId
emit('updateSelection', {
asset,
output,
canShowPreview: isFirst
})
}
const outputsRef = useTemplateRef('outputsRef')
useInfiniteScroll(outputsRef, outputs.loadMore, {
canLoadMore: () => outputs.hasMore.value
watchEffect(doEmit)
// Resolve in-progress items only when history outputs are loaded.
// Using watchEffect so it re-runs when allOutputs refs resolve (async).
watchEffect(() => {
if (store.pendingResolve.size === 0) return
for (const jobId of store.pendingResolve) {
const asset = outputs.media.value.find((a) => {
const m = getOutputAssetMetadata(a?.user_metadata)
return m?.jobId === jobId
})
if (!asset) continue
const loaded = toValue(allOutputs(asset)).length > 0
if (loaded) {
store.resolveIfReady(jobId, true)
if (!store.selectedId) selectFirstHistory()
}
}
})
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.jobId).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)
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
(newAssets, oldAssets) => {
@@ -133,52 +149,73 @@ watch(
(oldAssets.length === 0 && newAssets.length !== 1)
)
return
if (selectedIndex.value[0] <= 0) {
selectedIndex.value = [0, 0]
if (store.selectedId?.startsWith('slot:')) return
const sv = store.selectedId
? selectionMap.value.get(store.selectedId)
: undefined
if (!sv || sv.kind !== 'history') {
selectFirstHistory()
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]]
const wasFirst = sv.assetId === oldAssets[0]?.id
if (wasFirst || !newAssets.some((a) => a.id === sv.assetId)) {
selectFirstHistory()
}
}
)
function gotoNextOutput() {
const [index, key] = selectedIndex.value
if (index < 0 || key < 0) {
selectedIndex.value = [0, 0]
return
function selectFirstHistory() {
const first = outputs.media.value[0]
if (first) {
store.selectAsLatest(`history:${first.id}:0`)
} else {
store.selectAsLatest(null)
}
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
}
const outputsRef = useTemplateRef('outputsRef')
useInfiniteScroll(outputsRef, outputs.loadMore, {
canLoadMore: () => outputs.hasMore.value
})
if (index > 0) {
const len = outputCount(outputs.media.value[index - 1])
selectedIndex.value = [index - 1, len - 1]
return
}
selectedIndex.value = [0, 0]
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
// the global keybinding handler on window. Intercept in capture phase
// and re-dispatch from above the Listbox.
function onModifierEnter(e: KeyboardEvent) {
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey || e.shiftKey)) return
e.stopImmediatePropagation()
outputsRef.value?.parentElement?.dispatchEvent(
new KeyboardEvent('keydown', {
key: e.key,
code: e.code,
ctrlKey: e.ctrlKey,
metaKey: e.metaKey,
shiftKey: e.shiftKey,
altKey: e.altKey,
bubbles: true,
cancelable: true
})
)
}
let pointer = new CanvasPointer(document.body)
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))
const next = items[nextIdx]
store.select(next.id)
nextTick(() => listboxRef.value?.highlightItem(next))
}
const pointer = new CanvasPointer(document.body)
let scrollOffset = 0
useEventListener(
document.body,
@@ -189,23 +226,34 @@ useEventListener(
e.stopPropagation()
if (!pointer.isTrackpadGesture(e)) {
if (e.deltaY > 0) gotoNextOutput()
else gotoPreviousOutput()
if (e.deltaY > 0) navigateToAdjacent(1)
else navigateToAdjacent(-1)
return
}
scrollOffset += e.deltaY
while (scrollOffset >= 60) {
scrollOffset -= 60
gotoNextOutput()
navigateToAdjacent(1)
}
while (scrollOffset <= -60) {
scrollOffset += 60
gotoPreviousOutput()
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 }
)
useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
if (
(e.key !== 'ArrowDown' && e.key !== 'ArrowUp') ||
@@ -216,85 +264,95 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.key === 'ArrowDown') gotoNextOutput()
else gotoPreviousOutput()
if (e.key === 'ArrowDown') navigateToAdjacent(1)
else navigateToAdjacent(-1)
})
</script>
<template>
<article
ref="outputsRef"
data-testid="linear-outputs"
class="h-24 p-3 overflow-x-auto overflow-y-clip border-node-component-border flex items-center contain-size md:mx-15"
<ListboxRoot
ref="listboxRef"
:model-value="selectedValue"
orientation="horizontal"
selection-behavior="replace"
by="id"
class="min-w-0"
@update:model-value="onSelectionChange"
>
<section
v-if="
queueStore.runningTasks.length > 0 || queueStore.pendingTasks.length > 0
"
data-testid="linear-job"
class="py-3 h-24 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">
<ListboxContent as-child>
<article
ref="outputsRef"
data-testid="linear-outputs"
class="p-3 overflow-y-clip overflow-x-auto min-w-0"
@keydown.capture="onModifierEnter"
>
<div class="flex items-center gap-0.5 mx-auto w-fit">
<div v-if="queueCount > 0" class="shrink-0 flex items-center gap-0.5">
<div
class="shrink-0 p-1 border-2 border-transparent relative"
data-testid="linear-job"
>
<div
class="size-10 rounded-sm bg-secondary-background flex items-center justify-center"
>
<i
class="icon-[lucide--loader-circle] size-4 animate-spin text-muted-foreground"
/>
</div>
<div
v-if="queueCount > 1"
class="absolute top-0 right-0 min-w-4 h-4 flex justify-center items-center rounded-full bg-primary-background text-text-primary text-xs"
v-text="queueCount"
/>
</div>
<div
v-if="hasActiveContent || visibleHistory.length > 0"
class="border-l border-border-default h-12 shrink-0 mx-4"
/>
</div>
<ListboxItem
v-for="item in store.inProgressItems"
:key="`${item.id}-${item.state}`"
:value="{
id: `slot:${item.id}`,
kind: 'inProgress',
itemId: item.id
}"
:class="itemClass"
>
<OutputPreviewItem
v-if="item.state !== 'image' || !item.output"
:latent-preview="item.latentPreviewUrl"
/>
<OutputHistoryItem v-else :output="item.output" />
</ListboxItem>
<div
:class="progressBarPrimaryClass"
:style="progressPercentStyle(totalPercent)"
/>
<div
:class="progressBarSecondaryClass"
:style="progressPercentStyle(currentNodePercent)"
v-if="hasActiveContent && visibleHistory.length > 0"
class="border-l border-border-default h-12 shrink-0 mx-4"
/>
<template v-for="(asset, aIdx) in visibleHistory" :key="asset.id">
<div
v-if="aIdx > 0"
class="border-l border-border-default h-12 shrink-0 mx-4"
/>
<ListboxItem
v-for="(output, key) in toValue(allOutputs(asset))"
:key
:value="{
id: `history:${asset.id}:${key}`,
kind: 'history',
assetId: asset.id,
key
}"
:class="itemClass"
>
<OutputHistoryItem :output="output" />
</ListboxItem>
</template>
</div>
</div>
</section>
<template v-for="(item, index) in outputs.media.value" :key="index">
<div class="border-border-subtle border-l first:border-none h-21 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 h-20',
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>
</article>
</ListboxContent>
</ListboxRoot>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import {
getMediaType,
mediaTypes
} from '@/renderer/extensions/linearMode/mediaTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { output } = defineProps<{
output: ResultItemImpl
}>()
</script>
<template>
<img
v-if="getMediaType(output) === 'images'"
class="block size-10 rounded-sm object-cover bg-secondary-background"
loading="lazy"
width="40"
height="40"
:src="output.url"
/>
<i
v-else
:class="cn(mediaTypes[getMediaType(output)]?.iconClass, 'block size-10')"
/>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import LinearProgressBar from '@/renderer/extensions/linearMode/LinearProgressBar.vue'
const { latentPreview } = defineProps<{
latentPreview?: string
}>()
</script>
<template>
<div class="relative size-10">
<img
v-if="latentPreview"
class="block size-10 rounded-sm object-cover"
:src="latentPreview"
/>
<div v-else class="size-10 rounded-sm skeleton-shimmer" />
<LinearProgressBar
class="absolute inset-0 size-full bg-transparent"
:overall-opacity="0.7"
/>
</div>
</template>

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import type { NodeExecutionOutput } from '@/schemas/apiSchema'
function makeOutput(
overrides: Partial<NodeExecutionOutput> = {}
): NodeExecutionOutput {
return { ...overrides }
}
describe(flattenNodeOutput, () => {
it('returns empty array for output with no known media types', () => {
const result = flattenNodeOutput(['1', makeOutput({ text: 'hello' })])
expect(result).toEqual([])
})
it('flattens images into ResultItemImpl instances', () => {
const output = makeOutput({
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: 'sub', type: 'output' }
]
})
const result = flattenNodeOutput(['42', output])
expect(result).toHaveLength(2)
expect(result[0].filename).toBe('a.png')
expect(result[0].nodeId).toBe('42')
expect(result[0].mediaType).toBe('images')
expect(result[1].filename).toBe('b.png')
expect(result[1].subfolder).toBe('sub')
})
it('flattens audio outputs', () => {
const output = makeOutput({
audio: [{ filename: 'sound.wav', subfolder: '', type: 'output' }]
})
const result = flattenNodeOutput([7, output])
expect(result).toHaveLength(1)
expect(result[0].mediaType).toBe('audio')
expect(result[0].nodeId).toBe(7)
})
it('flattens multiple media types in a single output', () => {
const output = makeOutput({
images: [{ filename: 'img.png', subfolder: '', type: 'output' }],
video: [{ filename: 'vid.mp4', subfolder: '', type: 'output' }]
})
const result = flattenNodeOutput(['1', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('images')
expect(types).toContain('video')
})
it('handles gifs and 3d output types', () => {
const output = makeOutput({
gifs: [
{ filename: 'anim.gif', subfolder: '', type: 'output' }
] as NodeExecutionOutput['gifs'],
'3d': [
{ filename: 'model.glb', subfolder: '', type: 'output' }
] as NodeExecutionOutput['3d']
})
const result = flattenNodeOutput(['5', output])
expect(result).toHaveLength(2)
const types = result.map((r) => r.mediaType)
expect(types).toContain('gifs')
expect(types).toContain('3d')
})
it('ignores empty arrays', () => {
const output = makeOutput({ images: [], audio: [] })
const result = flattenNodeOutput(['1', output])
expect(result).toEqual([])
})
})

View File

@@ -0,0 +1,20 @@
import type { NodeExecutionOutput, ResultItem } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
export 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 })
)
)
}

View File

@@ -0,0 +1,21 @@
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
export interface InProgressItem {
id: string
jobId: string
state: 'skeleton' | 'latent' | 'image'
latentPreviewUrl?: string
output?: ResultItemImpl
}
export interface OutputSelection {
asset?: AssetItem
output?: ResultItemImpl
canShowPreview: boolean
latentPreviewUrl?: string
}
export type SelectionValue =
| { id: string; kind: 'inProgress'; itemId: string }
| { id: string; kind: 'history'; assetId: string; key: number }

View File

@@ -0,0 +1,534 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { ResultItemImpl } from '@/stores/queueStore'
const activeJobIdRef = ref<string | null>(null)
const previewsRef = ref<Record<string, string>>({})
const isAppModeRef = ref(true)
const { apiTarget } = vi.hoisted(() => ({
apiTarget: new EventTarget()
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
get isAppMode() {
return isAppModeRef.value
}
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get activeJobId() {
return activeJobIdRef.value
}
})
}))
vi.mock('@/stores/jobPreviewStore', () => ({
useJobPreviewStore: () => ({
get previewsByPromptId() {
return previewsRef.value
}
})
}))
vi.mock('@/scripts/api', () => ({
api: Object.assign(apiTarget, {
apiURL: (path: string) => path
})
}))
vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
flattenNodeOutput: ([nodeId, output]: [
string | number,
Record<string, unknown>
]) => {
if (!output.images) return []
return (output.images as Array<Record<string, string>>).map(
(img) =>
new ResultItemImpl({
...img,
nodeId: String(nodeId),
mediaType: 'images'
})
)
}
}))
function makeExecutedDetail(
promptId: string,
images: Array<Record<string, string>> = [
{ filename: 'out.png', subfolder: '', type: 'output' }
]
): ExecutedWsMessage {
return {
prompt_id: promptId,
node: '1',
display_node: '1',
output: { images }
} as ExecutedWsMessage
}
describe('linearOutputStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
activeJobIdRef.value = null
previewsRef.value = {}
isAppModeRef.value = true
})
afterEach(() => {
activeJobIdRef.value = null
previewsRef.value = {}
})
it('creates a skeleton item when a job starts', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
expect(store.inProgressItems).toHaveLength(1)
expect(store.inProgressItems[0].state).toBe('skeleton')
expect(store.inProgressItems[0].jobId).toBe('job-1')
})
it('auto-selects skeleton on first job start when no selection', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
expect(store.selectedId).toBe(`slot:${store.inProgressItems[0].id}`)
})
it('transitions to latent on preview', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
store.onJobStart('job-1')
const itemId = store.inProgressItems[0].id
store.onLatentPreview('job-1', 'blob:preview')
vi.advanceTimersByTime(16)
expect(store.inProgressItems[0].state).toBe('latent')
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview')
expect(store.selectedId).toBe(`slot:${itemId}`)
vi.useRealTimers()
})
it('ignores latent preview for other jobs', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onLatentPreview('job-other', 'blob:preview')
expect(store.inProgressItems[0].state).toBe('skeleton')
})
it('transitions to image on executed event', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
expect(imageItems).toHaveLength(1)
expect(imageItems[0].output).toBeDefined()
})
it('does not create trailing skeleton after executed output', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
expect(store.inProgressItems).toHaveLength(1)
expect(store.inProgressItems[0].state).toBe('image')
})
it('handles multi-output executed events', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: '', type: 'output' }
])
)
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
expect(imageItems).toHaveLength(2)
})
it('removes slots when job ends without image outputs', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
expect(store.inProgressItems).toHaveLength(1)
store.onJobComplete('job-1')
expect(store.inProgressItems).toHaveLength(0)
expect(store.pendingResolve.size).toBe(0)
})
it('adds to pendingResolve when job completes with images', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onJobComplete('job-1')
expect(store.inProgressItems.length).toBeGreaterThan(0)
expect(store.pendingResolve.has('job-1')).toBe(true)
})
it('removes items when history resolves', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onJobComplete('job-1')
expect(store.inProgressItems.length).toBeGreaterThan(0)
store.resolveIfReady('job-1', true)
expect(store.inProgressItems).toHaveLength(0)
expect(store.pendingResolve.has('job-1')).toBe(false)
})
it('does not resolve if history has not arrived', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onJobComplete('job-1')
store.resolveIfReady('job-1', false)
expect(store.inProgressItems.length).toBeGreaterThan(0)
expect(store.pendingResolve.has('job-1')).toBe(true)
})
it('does not auto-select when user is browsing history', () => {
const store = useLinearOutputStore()
// User manually selects a history item (browsing)
store.select('history:asset-2:0')
store.onJobStart('job-1')
// Should NOT yank to in-progress — user is browsing
expect(store.selectedId).toBe('history:asset-2:0')
store.onLatentPreview('job-1', 'blob:preview')
// Still should NOT yank
expect(store.selectedId).toBe('history:asset-2:0')
})
it('auto-selects on new job when following latest', () => {
const store = useLinearOutputStore()
// selectAsLatest simulates "following the latest output"
store.selectAsLatest('history:asset-1:0')
store.onJobStart('job-1')
// Following latest → auto-select new skeleton
expect(store.selectedId?.startsWith('slot:')).toBe(true)
})
it('does not auto-select on new job when browsing history', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
// User manually browses to an older output
store.select('history:asset-1:0')
store.onJobStart('job-2')
// Should NOT auto-select — user is browsing
expect(store.selectedId).toBe('history:asset-1:0')
})
it('falls back selection when selected item is removed', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
const firstId = `slot:${store.inProgressItems[0].id}`
expect(store.selectedId).toBe(firstId)
store.onJobComplete('job-1')
// Skeleton removed, no images, should clear selection
expect(store.selectedId).toBeNull()
})
it('creates skeleton on-demand when latent arrives after execute', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// No skeleton after execute
expect(
store.inProgressItems.filter((i) => i.state === 'skeleton')
).toHaveLength(0)
// Next node sends latent preview — skeleton created on demand
store.onLatentPreview('job-1', 'blob:next')
vi.advanceTimersByTime(16)
expect(store.inProgressItems[0].state).toBe('latent')
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:next')
expect(store.inProgressItems).toHaveLength(2)
vi.useRealTimers()
})
it('handles execute without prior skeleton (no latent preview)', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
// First node executes (consumes initial skeleton)
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
expect(store.inProgressItems).toHaveLength(1)
// Second node executes directly (no latent, no skeleton)
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'b.png', subfolder: '', type: 'output' }
])
)
const images = store.inProgressItems.filter((i) => i.state === 'image')
expect(images).toHaveLength(2)
})
it('does not fall back selection to stale items from other jobs', () => {
const store = useLinearOutputStore()
// Job 1 starts but is never completed (simulates watcher bug)
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Job 2 starts directly
store.onJobStart('job-2')
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
store.onJobComplete('job-2')
// Resolve job-2
store.resolveIfReady('job-2', true)
// Should clear to null for history takeover, NOT fall back to job-1
expect(store.selectedId).toBeNull()
})
it('transitions to latent via previewsByPromptId watcher', async () => {
vi.useFakeTimers()
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
activeJobIdRef.value = 'job-1'
await nextTick()
expect(store.inProgressItems).toHaveLength(1)
expect(store.inProgressItems[0].state).toBe('skeleton')
// Simulate jobPreviewStore update
previewsRef.value = { 'job-1': 'blob:preview-1' }
await nextTick()
vi.advanceTimersByTime(16)
expect(store.inProgressItems[0].state).toBe('latent')
expect(store.inProgressItems[0].latentPreviewUrl).toBe('blob:preview-1')
vi.useRealTimers()
})
it('completes previous job on direct job transition', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
activeJobIdRef.value = 'job-1'
await nextTick()
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
// Direct transition: job-1 → job-2 (no null in between)
activeJobIdRef.value = 'job-2'
await nextTick()
// job-1 should have been completed
expect(store.pendingResolve.has('job-1')).toBe(true)
// job-2 should have started
expect(store.inProgressItems.some((i) => i.jobId === 'job-2')).toBe(true)
})
it('two sequential runs: selection clears after each resolve', () => {
const store = useLinearOutputStore()
// Run 1: 3 outputs
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'b.png', subfolder: '', type: 'output' }
])
)
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'c.png', subfolder: '', type: 'output' }
])
)
const run1Images = store.inProgressItems.filter((i) => i.state === 'image')
expect(run1Images).toHaveLength(3)
store.onJobComplete('job-1')
store.resolveIfReady('job-1', true)
expect(store.selectedId).toBeNull()
expect(store.inProgressItems).toHaveLength(0)
// Simulate OutputHistory selecting run 1's first output (following latest)
store.selectAsLatest('history:asset-run1:0')
// Run 2: 3 outputs
store.onJobStart('job-2')
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
store.onNodeExecuted(
'job-2',
makeExecutedDetail('job-2', [
{ filename: 'e.png', subfolder: '', type: 'output' }
])
)
store.onNodeExecuted(
'job-2',
makeExecutedDetail('job-2', [
{ filename: 'f.png', subfolder: '', type: 'output' }
])
)
const run2Images = store.inProgressItems.filter((i) => i.state === 'image')
expect(run2Images).toHaveLength(3)
// Selection on run 2's latest output, not run 1's
expect(store.selectedId).toBe(`slot:${run2Images[0].id}`)
store.onJobComplete('job-2')
store.resolveIfReady('job-2', true)
// Must be null for history takeover — not a stale item
expect(store.selectedId).toBeNull()
expect(store.inProgressItems).toHaveLength(0)
})
it('keeps items visible across multiple resolveIfReady calls until loaded', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'b.png', subfolder: '', type: 'output' }
])
)
store.onJobComplete('job-1')
// History asset exists but outputs not loaded yet (async)
store.resolveIfReady('job-1', false)
expect(store.inProgressItems).toHaveLength(2)
expect(store.pendingResolve.has('job-1')).toBe(true)
// Still not loaded on next check
store.resolveIfReady('job-1', false)
expect(store.inProgressItems).toHaveLength(2)
// Outputs finally loaded
store.resolveIfReady('job-1', true)
expect(store.inProgressItems).toHaveLength(0)
expect(store.pendingResolve.has('job-1')).toBe(false)
})
it('does not remove in-progress items while history outputs are loading', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'b.png', subfolder: '', type: 'output' }
])
)
store.onNodeExecuted(
'job-1',
makeExecutedDetail('job-1', [
{ filename: 'c.png', subfolder: '', type: 'output' }
])
)
store.onJobComplete('job-1')
const itemCount = store.inProgressItems.length
expect(itemCount).toBe(3)
// History asset arrived but allOutputs() returns [] (still loading).
// Caller passes false — items must stay visible to prevent a gap
// where neither in-progress nor history items are rendered.
store.resolveIfReady('job-1', false)
expect(store.inProgressItems).toHaveLength(itemCount)
// Once allOutputs() loads, caller passes true — safe to resolve
store.resolveIfReady('job-1', true)
expect(store.inProgressItems).toHaveLength(0)
})
it('ignores executed events for other jobs', () => {
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-other', makeExecutedDetail('job-other'))
expect(store.inProgressItems.every((i) => i.state === 'skeleton')).toBe(
true
)
})
it('resets state when leaving app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
store.onJobStart('job-1')
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
store.select('slot:some-id')
expect(store.inProgressItems.length).toBeGreaterThan(0)
isAppModeRef.value = false
await nextTick()
expect(store.inProgressItems).toHaveLength(0)
expect(store.selectedId).toBeNull()
expect(store.trackedJobId).toBeNull()
expect(store.pendingResolve.size).toBe(0)
})
it('ignores execution events when not in app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()
isAppModeRef.value = false
await nextTick()
// Watcher-driven job start should be ignored
activeJobIdRef.value = 'job-1'
await nextTick()
expect(store.inProgressItems).toHaveLength(0)
})
})

View File

@@ -0,0 +1,272 @@
import { defineStore } from 'pinia'
import { ref, shallowRef, watch } from 'vue'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
export const useLinearOutputStore = defineStore('linearOutput', () => {
const appModeStore = useAppModeStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const inProgressItems = ref<InProgressItem[]>([])
const selectedId = ref<string | null>(null)
const isFollowing = ref(true)
const trackedJobId = ref<string | null>(null)
const pendingResolve = ref(new Set<string>())
let nextSeq = 0
function makeItemId(jobId: string): string {
return `job-${jobId}-${nextSeq++}`
}
function replaceItem(
id: string,
updater: (item: InProgressItem) => InProgressItem
) {
inProgressItems.value = inProgressItems.value.map((i) =>
i.id === id ? updater(i) : i
)
}
// --- Actions ---
const currentSkeletonId = shallowRef<string | null>(null)
function onJobStart(jobId: string) {
const item: InProgressItem = {
id: makeItemId(jobId),
jobId,
state: 'skeleton'
}
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
trackedJobId.value = jobId
autoSelect(`slot:${item.id}`)
}
let raf: number | null = null
function onLatentPreview(jobId: string, url: string) {
// Issue in Firefox where it doesnt seem to always re-render, wrapping in RAF fixes it
if (raf) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
const existing = inProgressItems.value.find(
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
)
if (existing) {
const wasEmpty = existing.state === 'skeleton'
replaceItem(existing.id, (i) => ({
...i,
state: 'latent',
latentPreviewUrl: url
}))
if (wasEmpty) autoSelect(`slot:${existing.id}`)
return
}
// Only create on-demand for the tracked job
if (jobId !== trackedJobId.value) return
const item: InProgressItem = {
id: makeItemId(jobId),
jobId,
state: 'latent',
latentPreviewUrl: url
}
currentSkeletonId.value = item.id
inProgressItems.value = [item, ...inProgressItems.value]
autoSelect(`slot:${item.id}`)
})
}
function onNodeExecuted(jobId: string, detail: ExecutedWsMessage) {
const nodeId = String(detail.display_node || detail.node)
const newOutputs = flattenNodeOutput([nodeId, detail.output])
if (newOutputs.length === 0) return
const skeletonItem = inProgressItems.value.find(
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
)
if (skeletonItem) {
const imageItem: InProgressItem = {
...skeletonItem,
state: 'image',
output: newOutputs[0],
latentPreviewUrl: undefined
}
autoSelect(`slot:${imageItem.id}`)
const extras: InProgressItem[] = newOutputs.slice(1).map((o) => ({
id: makeItemId(jobId),
jobId,
state: 'image' as const,
output: o
}))
const idx = inProgressItems.value.indexOf(skeletonItem)
const arr = [...inProgressItems.value]
arr.splice(idx, 1, imageItem, ...extras)
currentSkeletonId.value = null
inProgressItems.value = arr
return
}
// No skeleton — create image items directly (only for tracked job)
if (jobId !== trackedJobId.value) return
const newItems: InProgressItem[] = newOutputs.map((o) => ({
id: makeItemId(jobId),
jobId,
state: 'image' as const,
output: o
}))
autoSelect(`slot:${newItems[0].id}`)
inProgressItems.value = [...newItems, ...inProgressItems.value]
}
function onJobComplete(jobId: string) {
currentSkeletonId.value = null
const hasImages = inProgressItems.value.some(
(i) => i.jobId === jobId && i.state === 'image'
)
if (hasImages) {
// Remove non-image items (skeletons, latents), keep images for absorption
inProgressItems.value = inProgressItems.value.filter(
(i) => i.jobId !== jobId || i.state === 'image'
)
pendingResolve.value = new Set([...pendingResolve.value, jobId])
} else {
removeJobItems(jobId)
}
}
function removeJobItems(jobId: string) {
const removed = inProgressItems.value.filter((i) => i.jobId === jobId)
inProgressItems.value = inProgressItems.value.filter(
(i) => i.jobId !== jobId
)
if (
selectedId.value &&
removed.some((i) => `slot:${i.id}` === selectedId.value)
) {
selectedId.value = null
}
}
function resolveIfReady(jobId: string, historyLoaded: boolean) {
if (!pendingResolve.value.has(jobId)) return
if (!historyLoaded) return
const next = new Set(pendingResolve.value)
next.delete(jobId)
pendingResolve.value = next
removeJobItems(jobId)
}
function select(id: string | null) {
selectedId.value = id
isFollowing.value = false
}
function selectAsLatest(id: string | null) {
selectedId.value = id
isFollowing.value = true
}
function autoSelect(slotId: string) {
const sel = selectedId.value
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
selectedId.value = slotId
isFollowing.value = true
return
}
// User is browsing history — don't yank
}
// --- Event bindings (only active in app mode) ---
function handleExecuted({ detail }: CustomEvent<ExecutedWsMessage>) {
const jobId = detail.prompt_id
if (jobId !== executionStore.activeJobId) return
onNodeExecuted(jobId, detail)
}
function reset() {
if (raf) {
cancelAnimationFrame(raf)
raf = null
}
inProgressItems.value = []
selectedId.value = null
isFollowing.value = true
trackedJobId.value = null
currentSkeletonId.value = null
pendingResolve.value = new Set()
}
watch(
() => executionStore.activeJobId,
(jobId, oldJobId) => {
if (!appModeStore.isAppMode) return
if (oldJobId && oldJobId !== jobId) {
onJobComplete(oldJobId)
}
if (jobId) {
onJobStart(jobId)
}
}
)
watch(
() => jobPreviewStore.previewsByPromptId,
(previews) => {
if (!appModeStore.isAppMode) return
const jobId = executionStore.activeJobId
if (!jobId) return
const url = previews[jobId]
if (url) onLatentPreview(jobId, url)
},
{ deep: true }
)
watch(
() => appModeStore.isAppMode,
(active, wasActive) => {
if (active) {
api.addEventListener('executed', handleExecuted)
} else if (wasActive) {
api.removeEventListener('executed', handleExecuted)
reset()
}
},
{ immediate: true }
)
return {
inProgressItems,
selectedId,
trackedJobId,
pendingResolve,
select,
selectAsLatest,
resolveIfReady,
onJobStart,
onLatentPreview,
onNodeExecuted,
onJobComplete
}
})

View File

@@ -2,7 +2,7 @@ import { t } from '@/i18n'
import type { ResultItemImpl } from '@/stores/queueStore'
export type StatItem = { content?: string; iconClass?: string }
type StatItem = { content?: string; iconClass?: string }
export const mediaTypes: Record<string, StatItem> = {
'3d': {
content: t('sideToolbar.mediaAssets.filter3D'),

View File

@@ -0,0 +1,65 @@
import { useAsyncState } from '@vueuse/core'
import type { MaybeRef } from 'vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { getJobDetail } from '@/services/jobOutputCache'
import type { ResultItemImpl } from '@/stores/queueStore'
export function useOutputHistory(): {
outputs: IAssetsProvider
allOutputs: (item?: AssetItem) => MaybeRef<ResultItemImpl[]>
} {
const outputs = useMediaAssets('output')
void outputs.fetchMediaList()
const linearStore = useLinearOutputStore()
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
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 []
// For recently completed jobs still pending resolve, derive order from
// the in-progress items which are in correct execution order.
if (linearStore.pendingResolve.has(user_metadata.jobId)) {
const ordered = linearStore.inProgressItems
.filter((i) => i.jobId === user_metadata.jobId && i.output)
.map((i) => i.output!)
if (ordered.length > 0) {
outputsCache[item!.id] = ordered
return ordered
}
}
if (
user_metadata.allOutputs &&
user_metadata.outputCount &&
user_metadata.outputCount <= user_metadata.allOutputs.length
) {
const reversed = user_metadata.allOutputs.toReversed()
outputsCache[item!.id] = reversed
return reversed
}
const outputRef = useAsyncState(
getJobDetail(user_metadata.jobId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
return Object.entries(jobDetail.outputs)
.flatMap(flattenNodeOutput)
.toReversed()
}),
[]
).state
outputsCache[item!.id] = outputRef
return outputRef
}
return { outputs, allOutputs }
}

View File

@@ -18,6 +18,7 @@ const { locale } = useI18n()
const props = defineProps<{
widget: SimplifiedWidget<number>
rootClass?: string
}>()
function formatNumber(value: number, options?: Intl.NumberFormatOptions) {
@@ -156,7 +157,7 @@ const inputAriaAttrs = computed(() => ({
</script>
<template>
<WidgetLayoutField :widget>
<WidgetLayoutField :widget :root-class="props.rootClass">
<ScrubableNumberInput
v-model="modelValue"
v-tooltip="buttonTooltip"

View File

@@ -3,11 +3,12 @@ import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { useHideLayoutField } from '@/types/widgetTypes'
import { cn } from '@/utils/tailwindUtil'
defineProps<{
const { rootClass } = defineProps<{
widget: Pick<
SimplifiedWidget<string | number | undefined>,
'name' | 'label' | 'borderStyle'
>
rootClass?: string
}>()
const hideLayoutField = useHideLayoutField()
@@ -15,7 +16,12 @@ const hideLayoutField = useHideLayoutField()
<template>
<div
class="grid grid-cols-subgrid min-w-0 justify-between gap-1 text-node-component-slot-text"
:class="
cn(
'grid grid-cols-subgrid min-w-0 justify-between gap-1 text-node-component-slot-text',
rootClass
)
"
>
<div v-if="!hideLayoutField" class="truncate content-center-safe">
<template v-if="widget.name">