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,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()