App mode output feed to only show current session results for outputs defined in the app (#9307)

## Summary

Updates app mode to only show images from:
- the workflow that generated the image
- in the current session
- for the outputs selected in the builder

## Changes

- **What**: 
- adds new mapping of jobid -> workflow path [cant use id here as it is
not guaranteed unique], capped at 4k entries
- fix bug where executing a workflow then quickly switching tabs
associated incorrect workflow
- add missing output history tests

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9307-App-mode-output-feed-to-only-show-current-session-results-for-outputs-defined-in-the-app-3156d73d36508142b4bbca3f938fc5c2)
by [Unito](https://www.unito.io)
This commit is contained in:
pythongosssss
2026-03-02 19:10:20 +00:00
committed by GitHub
parent 1dd789fa54
commit 0d7dc15916
7 changed files with 692 additions and 85 deletions

View File

@@ -1,17 +1,9 @@
<script setup lang="ts">
import { useEventListener, useInfiniteScroll } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import { ListboxContent, ListboxItem, ListboxRoot } from 'reka-ui'
import {
computed,
nextTick,
toValue,
useTemplateRef,
watch,
watchEffect
} from 'vue'
import { computed, nextTick, useTemplateRef, watch, watchEffect } from 'vue'
import { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import OutputHistoryActiveQueueItem from '@/renderer/extensions/linearMode/OutputHistoryActiveQueueItem.vue'
import OutputHistoryItem from '@/renderer/extensions/linearMode/OutputHistoryItem.vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
@@ -24,7 +16,7 @@ import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHist
import { useQueueStore } from '@/stores/queueStore'
import { cn } from '@/utils/tailwindUtil'
const { outputs, allOutputs } = useOutputHistory()
const { outputs, allOutputs, selectFirstHistory } = useOutputHistory()
const queueStore = useQueueStore()
const store = useLinearOutputStore()
@@ -45,15 +37,17 @@ const itemClass = cn(
'data-[state=checked]:border-interface-panel-job-progress-border'
)
const hasActiveContent = computed(() => store.inProgressItems.length > 0)
const hasActiveContent = computed(
() => store.activeWorkflowInProgressItems.length > 0
)
const visibleHistory = computed(() =>
outputs.media.value.filter((a) => toValue(allOutputs(a)).length > 0)
outputs.media.value.filter((a) => allOutputs(a).length > 0)
)
const selectableItems = computed(() => {
const items: SelectionValue[] = []
for (const item of store.inProgressItems) {
for (const item of store.activeWorkflowInProgressItems) {
items.push({
id: `slot:${item.id}`,
kind: 'inProgress',
@@ -61,7 +55,7 @@ const selectableItems = computed(() => {
})
}
for (const asset of outputs.media.value) {
const outs = toValue(allOutputs(asset))
const outs = allOutputs(asset)
for (let k = 0; k < outs.length; k++) {
items.push({
id: `history:${asset.id}:${k}`,
@@ -95,7 +89,9 @@ function doEmit() {
return
}
if (sel.kind === 'inProgress') {
const item = store.inProgressItems.find((i) => i.id === sel.itemId)
const item = store.activeWorkflowInProgressItems.find(
(i) => i.id === sel.itemId
)
if (!item || item.state === 'skeleton') {
emit('updateSelection', { canShowPreview: true })
} else if (item.state === 'latent') {
@@ -112,7 +108,7 @@ function doEmit() {
return
}
const asset = outputs.media.value.find((a) => a.id === sel.assetId)
const output = asset ? toValue(allOutputs(asset))[sel.key] : undefined
const output = asset ? allOutputs(asset)[sel.key] : undefined
const isFirst = outputs.media.value[0]?.id === sel.assetId
emit('updateSelection', {
asset,
@@ -123,24 +119,6 @@ function doEmit() {
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()
}
}
})
// Keep history selection stable on media changes
watch(
() => outputs.media.value,
@@ -169,19 +147,7 @@ watch(
}
)
function selectFirstHistory() {
const first = outputs.media.value[0]
if (first) {
store.selectAsLatest(`history:${first.id}:0`)
} else {
store.selectAsLatest(null)
}
}
const outputsRef = useTemplateRef('outputsRef')
useInfiniteScroll(outputsRef, outputs.loadMore, {
canLoadMore: () => outputs.hasMore.value
})
// Reka UI's ListboxContent stops propagation on ALL Enter keydown events,
// which blocks modifier+Enter (Ctrl+Enter = run workflow) from reaching
@@ -296,7 +262,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
</div>
<ListboxItem
v-for="item in store.inProgressItems"
v-for="item in store.activeWorkflowInProgressItems"
:key="`${item.id}-${item.state}`"
:value="{
id: `slot:${item.id}`,
@@ -323,7 +289,7 @@ useEventListener(document.body, 'keydown', (e: KeyboardEvent) => {
class="border-l border-border-default h-12 shrink-0 mx-4"
/>
<ListboxItem
v-for="(output, key) in toValue(allOutputs(asset))"
v-for="(output, key) in allOutputs(asset)"
:key
:value="{
id: `history:${asset.id}:${key}`,

View File

@@ -1,5 +1,5 @@
import { createPinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
@@ -9,6 +9,9 @@ import { ResultItemImpl } from '@/stores/queueStore'
const activeJobIdRef = ref<string | null>(null)
const previewsRef = ref<Record<string, { url: string; nodeId?: string }>>({})
const isAppModeRef = ref(true)
const activeWorkflowPathRef = ref<string>('workflows/test-workflow.json')
const jobIdToWorkflowPathRef = ref(new Map<string, string>())
const selectedOutputsRef = ref<string[]>([])
const { apiTarget } = vi.hoisted(() => ({
apiTarget: new EventTarget()
@@ -20,10 +23,29 @@ vi.mock('@/composables/useAppMode', () => ({
})
}))
vi.mock('@/stores/appModeStore', () => ({
useAppModeStore: () => ({
get selectedOutputs() {
return selectedOutputsRef.value
}
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get activeJobId() {
return activeJobIdRef.value
},
get jobIdToSessionWorkflowPath() {
return jobIdToWorkflowPathRef.value
}
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return { path: activeWorkflowPathRef.value }
}
})
}))
@@ -59,6 +81,12 @@ vi.mock('@/renderer/extensions/linearMode/flattenNodeOutput', () => ({
}
}))
function setJobWorkflowPath(jobId: string, path: string) {
const next = new Map(jobIdToWorkflowPathRef.value)
next.set(jobId, path)
jobIdToWorkflowPathRef.value = next
}
function makeExecutedDetail(
promptId: string,
images: Array<Record<string, string>> = [
@@ -80,11 +108,9 @@ describe('linearOutputStore', () => {
activeJobIdRef.value = null
previewsRef.value = {}
isAppModeRef.value = true
})
afterEach(() => {
activeJobIdRef.value = null
previewsRef.value = {}
activeWorkflowPathRef.value = 'workflows/test-workflow.json'
jobIdToWorkflowPathRef.value = new Map()
selectedOutputsRef.value = []
})
it('creates a skeleton item when a job starts', () => {
@@ -613,10 +639,105 @@ describe('linearOutputStore', () => {
expect(store.inProgressItems).toHaveLength(0)
expect(store.selectedId).toBeNull()
expect(store.trackedJobId).toBeNull()
expect(store.pendingResolve.size).toBe(0)
})
it('does not show in-progress items from another workflow', () => {
const store = useLinearOutputStore()
// Job-1 submitted from workflow-a
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
// User switches to workflow-b: job-1 should NOT appear
activeWorkflowPathRef.value = 'workflows/app-b.json'
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// Back on workflow-a: job-1 should appear
activeWorkflowPathRef.value = 'workflows/app-a.json'
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-1')
})
it('uses executionStore path map for workflow scoping', () => {
const store = useLinearOutputStore()
// Simulate storeJob populating executionStore.jobIdToSessionWorkflowPath
setJobWorkflowPath('job-1', 'workflows/app-a.json')
setJobWorkflowPath('job-2', 'workflows/app-a.json')
// User switches to workflow-b before execution starts
activeWorkflowPathRef.value = 'workflows/app-b.json'
store.onJobStart('job-1')
store.onJobStart('job-2')
// On workflow-b: neither job should appear
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
// On workflow-a: both jobs should appear
activeWorkflowPathRef.value = 'workflows/app-a.json'
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
})
it('scopes in-progress items per workflow with concurrent jobs', () => {
vi.useFakeTimers()
const store = useLinearOutputStore()
// Job-1 on workflow-a (dog)
activeWorkflowPathRef.value = 'workflows/app-a.json'
setJobWorkflowPath('job-1', 'workflows/app-a.json')
store.onJobStart('job-1')
store.onLatentPreview('job-1', 'blob:dog')
vi.advanceTimersByTime(16)
// User switches to workflow-b, runs job-2
activeWorkflowPathRef.value = 'workflows/app-b.json'
setJobWorkflowPath('job-2', 'workflows/app-b.json')
// Job-1 finishes, job-2 starts
store.onJobComplete('job-1')
store.onJobStart('job-2')
store.onLatentPreview('job-2', 'blob:landscape')
vi.advanceTimersByTime(16)
// On workflow-b: should only see job-2 (landscape), NOT job-1 (dog)
const items = store.activeWorkflowInProgressItems
expect(items).toHaveLength(1)
expect(items[0].jobId).toBe('job-2')
expect(items[0].latentPreviewUrl).toBe('blob:landscape')
vi.useRealTimers()
})
it('skips output items for nodes not in selectedOutputs', () => {
selectedOutputsRef.value = ['2']
const store = useLinearOutputStore()
store.onJobStart('job-1')
// Node 1 executes — not in selectedOutputs, should be skipped
store.onNodeExecuted('job-1', makeExecutedDetail('job-1', undefined, '1'))
// Skeleton should still be there (not consumed by non-output node)
expect(
store.inProgressItems.filter((i) => i.state === 'image')
).toHaveLength(0)
// Node 2 executes — in selectedOutputs, should create image item
store.onNodeExecuted(
'job-1',
makeExecutedDetail(
'job-1',
[{ filename: 'out.png', subfolder: '', type: 'output' }],
'2'
)
)
const imageItems = store.inProgressItems.filter((i) => i.state === 'image')
expect(imageItems).toHaveLength(1)
expect(imageItems[0].output?.nodeId).toBe('2')
})
it('ignores execution events when not in app mode', async () => {
const { nextTick } = await import('vue')
const store = useLinearOutputStore()

View File

@@ -1,26 +1,41 @@
import { defineStore } from 'pinia'
import { ref, shallowRef, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useAppMode } from '@/composables/useAppMode'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import type { ResultItemImpl } from '@/stores/queueStore'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { useAppMode } from '@/composables/useAppMode'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useJobPreviewStore } from '@/stores/jobPreviewStore'
export const useLinearOutputStore = defineStore('linearOutput', () => {
const { isAppMode } = useAppMode()
const appModeStore = useAppModeStore()
const executionStore = useExecutionStore()
const jobPreviewStore = useJobPreviewStore()
const workflowStore = useWorkflowStore()
const inProgressItems = ref<InProgressItem[]>([])
const resolvedOutputsCache = new Map<string, ResultItemImpl[]>()
const selectedId = ref<string | null>(null)
const isFollowing = ref(true)
const trackedJobId = ref<string | null>(null)
const pendingResolve = ref(new Set<string>())
const executedNodeIds = new Set<string>()
const activeWorkflowInProgressItems = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
const all = inProgressItems.value
return all.filter(
(i) => executionStore.jobIdToSessionWorkflowPath.get(i.jobId) === path
)
})
let nextSeq = 0
function makeItemId(jobId: string): string {
@@ -42,6 +57,7 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
function onJobStart(jobId: string) {
executedNodeIds.clear()
const item: InProgressItem = {
id: makeItemId(jobId),
jobId,
@@ -101,6 +117,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
const newOutputs = flattenNodeOutput([nodeId, detail.output])
if (newOutputs.length === 0) return
// Skip output items for nodes not flagged as output nodes
const outputNodeIds = appModeStore.selectedOutputs
if (
outputNodeIds.length > 0 &&
!outputNodeIds.some((id) => String(id) === String(nodeId))
)
return
const skeletonItem = inProgressItems.value.find(
(i) => i.id === currentSkeletonId.value && i.jobId === jobId
)
@@ -273,14 +297,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
)
return {
inProgressItems,
activeWorkflowInProgressItems,
resolvedOutputsCache,
selectedId,
trackedJobId,
pendingResolve,
select,
selectAsLatest,
resolveIfReady,
inProgressItems,
onJobStart,
onLatentPreview,
onNodeExecuted,

View File

@@ -0,0 +1,381 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import type { InProgressItem } from '@/renderer/extensions/linearMode/linearModeTypes'
import { useOutputHistory } from '@/renderer/extensions/linearMode/useOutputHistory'
import { useAppModeStore } from '@/stores/appModeStore'
import { ResultItemImpl } from '@/stores/queueStore'
const mediaRef = ref<AssetItem[]>([])
const pendingResolveRef = ref(new Set<string>())
const inProgressItemsRef = ref<InProgressItem[]>([])
const selectedIdRef = ref<string | null>(null)
const activeWorkflowPathRef = ref<string>('workflows/test.json')
const jobIdToPathRef = ref(new Map<string, string>())
const selectAsLatestFn = vi.fn()
const resolveIfReadyFn = vi.fn()
const resolvedOutputsCacheRef = new Map<string, ResultItemImpl[]>()
vi.mock('@/platform/assets/composables/media/useMediaAssets', () => ({
useMediaAssets: () => ({
media: mediaRef,
loading: ref(false),
error: ref(null),
fetchMediaList: vi.fn().mockResolvedValue([]),
refresh: vi.fn().mockResolvedValue([]),
loadMore: vi.fn(),
hasMore: ref(false),
isLoadingMore: ref(false)
})
}))
vi.mock('@/renderer/extensions/linearMode/linearOutputStore', () => ({
useLinearOutputStore: () => ({
get pendingResolve() {
return pendingResolveRef.value
},
get inProgressItems() {
return inProgressItemsRef.value
},
get selectedId() {
return selectedIdRef.value
},
resolvedOutputsCache: resolvedOutputsCacheRef,
selectAsLatest: selectAsLatestFn,
resolveIfReady: resolveIfReadyFn
})
}))
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
useWorkflowStore: () => ({
get activeWorkflow() {
return { path: activeWorkflowPathRef.value }
}
})
}))
vi.mock('@/stores/executionStore', () => ({
useExecutionStore: () => ({
get jobIdToSessionWorkflowPath() {
return jobIdToPathRef.value
}
})
}))
const { jobDetailResults } = vi.hoisted(() => ({
jobDetailResults: new Map<string, unknown>()
}))
vi.mock('@/services/jobOutputCache', () => ({
getJobDetail: (jobId: string) =>
Promise.resolve(jobDetailResults.get(jobId) ?? undefined)
}))
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 makeAsset(
id: string,
jobId: string,
opts?: { allOutputs?: ResultItemImpl[]; outputCount?: number }
): AssetItem {
return {
id,
name: `${id}.png`,
tags: [],
user_metadata: {
jobId,
nodeId: '1',
subfolder: '',
...(opts?.allOutputs ? { allOutputs: opts.allOutputs } : {}),
...(opts?.outputCount !== undefined
? { outputCount: opts.outputCount }
: {})
}
}
}
function makeResult(filename: string, nodeId: string = '1'): ResultItemImpl {
return new ResultItemImpl({
filename,
subfolder: '',
type: 'output',
nodeId,
mediaType: 'images'
})
}
describe(useOutputHistory, () => {
beforeEach(() => {
setActivePinia(createPinia())
mediaRef.value = []
pendingResolveRef.value = new Set()
inProgressItemsRef.value = []
selectedIdRef.value = null
activeWorkflowPathRef.value = 'workflows/test.json'
jobIdToPathRef.value = new Map()
resolvedOutputsCacheRef.clear()
jobDetailResults.clear()
selectAsLatestFn.mockReset()
resolveIfReadyFn.mockReset()
})
describe('sessionMedia filtering', () => {
it('filters assets to match active workflow path', () => {
jobIdToPathRef.value = new Map([
['job-1', 'workflows/test.json'],
['job-2', 'workflows/other.json']
])
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a1')
})
it('returns empty when no workflow is active', () => {
activeWorkflowPathRef.value = ''
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
mediaRef.value = [makeAsset('a1', 'job-1')]
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(0)
})
it('updates when active workflow changes', async () => {
jobIdToPathRef.value = new Map([
['job-1', 'workflows/a.json'],
['job-2', 'workflows/b.json']
])
mediaRef.value = [makeAsset('a1', 'job-1'), makeAsset('a2', 'job-2')]
activeWorkflowPathRef.value = 'workflows/a.json'
const { outputs } = useOutputHistory()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a1')
activeWorkflowPathRef.value = 'workflows/b.json'
await nextTick()
expect(outputs.media.value).toHaveLength(1)
expect(outputs.media.value[0].id).toBe('a2')
})
})
describe('allOutputs', () => {
it('returns empty for undefined item', () => {
const { allOutputs } = useOutputHistory()
expect(allOutputs()).toEqual([])
expect(allOutputs(undefined)).toEqual([])
})
it('returns outputs from metadata allOutputs when count matches', () => {
const results = [makeResult('a.png'), makeResult('b.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 2
})
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(2)
// Should be reversed
expect(outputs[0].filename).toBe('b.png')
expect(outputs[1].filename).toBe('a.png')
})
it('filters outputs to selected output nodes only', () => {
const results = [
makeResult('a.png', '1'),
makeResult('b.png', '2'),
makeResult('c.png', '3')
]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 3
})
const appModeStore = useAppModeStore()
appModeStore.selectedOutputs.push('2')
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(1)
expect(outputs[0].filename).toBe('b.png')
})
it('returns all outputs when no output nodes are selected', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 2
})
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(2)
})
it('returns consistent filtered outputs across repeated calls', () => {
const results = [makeResult('a.png', '1'), makeResult('b.png', '2')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 2
})
const appModeStore = useAppModeStore()
appModeStore.selectedOutputs.push('2')
const { allOutputs } = useOutputHistory()
const first = allOutputs(asset)
const second = allOutputs(asset)
expect(first).toEqual(second)
expect(first).toHaveLength(1)
expect(first[0].filename).toBe('b.png')
})
it('returns in-progress outputs for pending resolve jobs', () => {
pendingResolveRef.value = new Set(['job-1'])
inProgressItemsRef.value = [
{
id: 'item-1',
jobId: 'job-1',
state: 'image',
output: makeResult('a.png')
},
{
id: 'item-2',
jobId: 'job-1',
state: 'image',
output: makeResult('b.png')
}
]
const asset = makeAsset('a1', 'job-1')
const { allOutputs } = useOutputHistory()
const outputs = allOutputs(asset)
expect(outputs).toHaveLength(2)
expect(outputs[0].filename).toBe('a.png')
expect(outputs[1].filename).toBe('b.png')
})
it('fetches full job detail for multi-output jobs', async () => {
jobDetailResults.set('job-1', {
outputs: {
'1': {
images: [
{ filename: 'a.png', subfolder: '', type: 'output' },
{ filename: 'b.png', subfolder: '', type: 'output' },
{ filename: 'c.png', subfolder: '', type: 'output' }
]
}
}
})
const asset = makeAsset('a1', 'job-1')
const { allOutputs } = useOutputHistory()
expect(allOutputs(asset)).toEqual([])
await nextTick()
await nextTick()
const resolved = allOutputs(asset)
expect(resolved).toHaveLength(3)
expect(resolved[0].filename).toBe('c.png')
})
})
describe('watchEffect resolve loop', () => {
it('resolves pending jobs when history outputs load', async () => {
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 1
})
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
pendingResolveRef.value = new Set(['job-1'])
mediaRef.value = [asset]
selectedIdRef.value = null
useOutputHistory()
await nextTick()
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
expect(selectAsLatestFn).toHaveBeenCalledWith('history:a1:0')
})
it('does not select first history when a selection exists', async () => {
const results = [makeResult('a.png')]
const asset = makeAsset('a1', 'job-1', {
allOutputs: results,
outputCount: 1
})
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
pendingResolveRef.value = new Set(['job-1'])
mediaRef.value = [asset]
selectedIdRef.value = 'history:existing:0'
useOutputHistory()
await nextTick()
expect(resolveIfReadyFn).toHaveBeenCalledWith('job-1', true)
expect(selectAsLatestFn).not.toHaveBeenCalled()
})
it('skips jobs with no matching asset in media', async () => {
pendingResolveRef.value = new Set(['job-missing'])
mediaRef.value = []
useOutputHistory()
await nextTick()
expect(resolveIfReadyFn).not.toHaveBeenCalled()
})
})
describe('selectFirstHistory', () => {
it('selects first media item', () => {
jobIdToPathRef.value = new Map([['job-1', 'workflows/test.json']])
mediaRef.value = [makeAsset('a1', 'job-1')]
const { selectFirstHistory } = useOutputHistory()
selectFirstHistory()
expect(selectAsLatestFn).toHaveBeenCalledWith('history:a1:0')
})
it('selects null when no media', () => {
const { selectFirstHistory } = useOutputHistory()
selectFirstHistory()
expect(selectAsLatestFn).toHaveBeenCalledWith(null)
})
})
})

View File

@@ -1,29 +1,71 @@
import { useAsyncState } from '@vueuse/core'
import type { MaybeRef } from 'vue'
import { computed, ref, watchEffect } from 'vue'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import type { IAssetsProvider } from '@/platform/assets/composables/media/IAssetsProvider'
import { useMediaAssets } from '@/platform/assets/composables/media/useMediaAssets'
import { getOutputAssetMetadata } from '@/platform/assets/schemas/assetMetadataSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { flattenNodeOutput } from '@/renderer/extensions/linearMode/flattenNodeOutput'
import { useLinearOutputStore } from '@/renderer/extensions/linearMode/linearOutputStore'
import { getJobDetail } from '@/services/jobOutputCache'
import { useAppModeStore } from '@/stores/appModeStore'
import { useExecutionStore } from '@/stores/executionStore'
import type { ResultItemImpl } from '@/stores/queueStore'
export function useOutputHistory(): {
outputs: IAssetsProvider
allOutputs: (item?: AssetItem) => MaybeRef<ResultItemImpl[]>
allOutputs: (item?: AssetItem) => ResultItemImpl[]
selectFirstHistory: () => void
} {
const outputs = useMediaAssets('output')
void outputs.fetchMediaList()
const backingOutputs = useMediaAssets('output')
void backingOutputs.fetchMediaList()
const linearStore = useLinearOutputStore()
const workflowStore = useWorkflowStore()
const executionStore = useExecutionStore()
const appModeStore = useAppModeStore()
const outputsCache: Record<string, MaybeRef<ResultItemImpl[]>> = {}
function filterByOutputNodes(items: ResultItemImpl[]): ResultItemImpl[] {
const nodeIds = appModeStore.selectedOutputs
if (!nodeIds.length) return items
return items.filter((r) =>
nodeIds.some((id) => String(id) === String(r.nodeId))
)
}
function allOutputs(item?: AssetItem): MaybeRef<ResultItemImpl[]> {
if (item?.id && outputsCache[item.id]) return outputsCache[item.id]
const sessionMedia = computed(() => {
const path = workflowStore.activeWorkflow?.path
if (!path) return []
const user_metadata = getOutputAssetMetadata(item?.user_metadata)
const pathMap = executionStore.jobIdToSessionWorkflowPath
return backingOutputs.media.value.filter((asset) => {
const m = getOutputAssetMetadata(asset?.user_metadata)
return m ? pathMap.get(m.jobId) === path : false
})
})
const outputs: IAssetsProvider = {
...backingOutputs,
media: sessionMedia,
hasMore: ref(false),
isLoadingMore: ref(false),
loadMore: async () => {}
}
const resolvedCache = linearStore.resolvedOutputsCache
const asyncRefs = new Map<
string,
ReturnType<typeof useAsyncState<ResultItemImpl[]>>['state']
>()
function allOutputs(item?: AssetItem): ResultItemImpl[] {
if (!item?.id) return []
const cached = resolvedCache.get(item.id)
if (cached) return filterByOutputNodes(cached)
const user_metadata = getOutputAssetMetadata(item.user_metadata)
if (!user_metadata) return []
// For recently completed jobs still pending resolve, derive order from
@@ -33,33 +75,70 @@ export function useOutputHistory(): {
.filter((i) => i.jobId === user_metadata.jobId && i.output)
.map((i) => i.output!)
if (ordered.length > 0) {
outputsCache[item!.id] = ordered
return ordered
resolvedCache.set(item.id, ordered)
return filterByOutputNodes(ordered)
}
}
// Use metadata when all outputs are present. The /jobs list endpoint
// only returns preview_output (single item), so outputCount may exceed
// allOutputs.length for multi-output jobs.
if (
user_metadata.allOutputs &&
user_metadata.outputCount &&
user_metadata.outputCount <= user_metadata.allOutputs.length
user_metadata.allOutputs?.length &&
(!user_metadata.outputCount ||
user_metadata.outputCount <= user_metadata.allOutputs.length)
) {
const reversed = user_metadata.allOutputs.toReversed()
outputsCache[item!.id] = reversed
return reversed
resolvedCache.set(item.id, reversed)
return filterByOutputNodes(reversed)
}
// Async fallback for multi-output jobs — fetch full /jobs/{id} detail.
// This can be hit if the user executes the job then switches tabs.
const existing = asyncRefs.get(item.id)
if (existing) return filterByOutputNodes(existing.value)
const itemId = item.id
const outputRef = useAsyncState(
getJobDetail(user_metadata.jobId).then((jobDetail) => {
if (!jobDetail?.outputs) return []
return Object.entries(jobDetail.outputs)
const results = Object.entries(jobDetail.outputs)
.flatMap(flattenNodeOutput)
.toReversed()
resolvedCache.set(itemId, results)
return results
}),
[]
).state
outputsCache[item!.id] = outputRef
return outputRef
asyncRefs.set(item.id, outputRef)
return filterByOutputNodes(outputRef.value)
}
return { outputs, allOutputs }
function selectFirstHistory() {
const first = outputs.media.value[0]
if (first) {
linearStore.selectAsLatest(`history:${first.id}:0`)
} else {
linearStore.selectAsLatest(null)
}
}
// Resolve in-progress items when history outputs are loaded.
watchEffect(() => {
if (linearStore.pendingResolve.size === 0) return
for (const jobId of linearStore.pendingResolve) {
const asset = outputs.media.value.find((a) => {
const m = getOutputAssetMetadata(a?.user_metadata)
return m?.jobId === jobId
})
if (!asset) continue
const loaded = allOutputs(asset).length > 0
if (loaded) {
linearStore.resolveIfReady(jobId, true)
if (!linearStore.selectedId) selectFirstHistory()
}
}
})
return { outputs, allOutputs, selectFirstHistory }
}

View File

@@ -1491,6 +1491,10 @@ export class ComfyApp {
}
})
// Capture workflow before await — activeWorkflow may change if the
// user switches tabs while the request is in flight.
const queuedWorkflow = useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
const p = await this.graphToPrompt(this.rootGraph)
const queuedNodes = collectAllNodes(this.rootGraph)
try {
@@ -1511,8 +1515,7 @@ export class ComfyApp {
executionStore.storeJob({
id: res.prompt_id,
nodes: Object.keys(p.output),
workflow: useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
workflow: queuedWorkflow
})
}
} catch (error) {}

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, ref, shallowRef } from 'vue'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import { isCloud } from '@/platform/distribution/types'
@@ -65,6 +65,12 @@ export const useExecutionStore = defineStore('execution', () => {
*/
const jobIdToWorkflowId = ref<Map<string, string>>(new Map())
/**
* Map of job ID to workflow file path in the current session.
* Only populated for jobs that are queued in this browser tab.
*/
const jobIdToSessionWorkflowPath = shallowRef<Map<string, string>>(new Map())
const initializingJobIds = ref<Set<string>>(new Set())
const mergeExecutionProgressStates = (
@@ -218,6 +224,13 @@ export const useExecutionStore = defineStore('execution', () => {
activeJobId.value = e.detail.prompt_id
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
clearInitializationByJobId(activeJobId.value)
// Ensure path mapping exists — execution_start can arrive via WebSocket
// before the HTTP response from queuePrompt triggers storeJob.
if (!jobIdToSessionWorkflowPath.value.has(activeJobId.value)) {
const path = queuedJobs.value[activeJobId.value]?.workflow?.path
if (path) ensureSessionWorkflowPath(activeJobId.value, path)
}
}
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
@@ -482,6 +495,24 @@ export const useExecutionStore = defineStore('execution', () => {
if (wid) {
jobIdToWorkflowId.value.set(String(id), String(wid))
}
if (workflow?.path) {
ensureSessionWorkflowPath(String(id), workflow.path)
}
}
// ~0.65 MB at capacity (32 char GUID key + 50 char path value)
const MAX_SESSION_PATH_ENTRIES = 4000
function ensureSessionWorkflowPath(jobId: string, path: string) {
if (jobIdToSessionWorkflowPath.value.get(jobId) === path) return
const next = new Map(jobIdToSessionWorkflowPath.value)
next.set(jobId, path)
while (next.size > MAX_SESSION_PATH_ENTRIES) {
const oldest = next.keys().next().value
if (oldest !== undefined) next.delete(oldest)
else break
}
jobIdToSessionWorkflowPath.value = next
}
/**
@@ -550,6 +581,8 @@ export const useExecutionStore = defineStore('execution', () => {
_executingNodeProgress,
// NodeLocatorId conversion helpers
nodeLocatorIdToExecutionId,
jobIdToWorkflowId
jobIdToWorkflowId,
jobIdToSessionWorkflowPath,
ensureSessionWorkflowPath
}
})