mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 16:40:05 +00:00
fix: switching tabs in app mode clearing outputs (#9745)
## Summary - remove reset on exiting app mode and instead cleanup at specific stages instead of a reset all - more job<->workflow specificity updates - ensure pending data is cleared up and doesnt leak over time ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9745-fix-switching-tabs-in-app-mode-clearing-outputs-3206d73d365081038cb0c83f0d953e71) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -365,6 +365,7 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
@@ -387,12 +388,14 @@ describe('linearOutputStore', () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Direct transition: job-1 → job-2 (no null in between)
|
||||
setJobWorkflowPath('job-2', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-2'
|
||||
await nextTick()
|
||||
|
||||
@@ -631,7 +634,7 @@ describe('linearOutputStore', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('resets state when leaving app mode', async () => {
|
||||
it('preserves in-progress items when leaving app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
@@ -643,9 +646,65 @@ describe('linearOutputStore', () => {
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
expect(store.selectedId).toBeNull()
|
||||
expect(store.pendingResolve.size).toBe(0)
|
||||
expect(store.inProgressItems.length).toBeGreaterThan(0)
|
||||
expect(store.selectedId).toBe('slot:some-id')
|
||||
})
|
||||
|
||||
it('completes stale tracked job when re-entering app mode', async () => {
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
|
||||
// Switch away — job finishes while we're gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back — store should reconcile the stale tracked job
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('recovers latent preview when re-entering app mode', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
activeJobIdRef.value = 'job-1'
|
||||
await nextTick()
|
||||
|
||||
// First node executes, consuming the skeleton
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
expect(store.inProgressItems[0].state).toBe('image')
|
||||
|
||||
// Switch away — latent preview arrives for next node while gone
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
previewsRef.value = {
|
||||
'job-1': { url: 'blob:preview-while-away', nodeId: 'node-2' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back — should recover the latent preview
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const latentItems = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(latentItems).toHaveLength(1)
|
||||
expect(latentItems[0].latentPreviewUrl).toBe('blob:preview-while-away')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not show in-progress items from another workflow', () => {
|
||||
@@ -785,4 +844,472 @@ describe('linearOutputStore', () => {
|
||||
|
||||
expect(store.inProgressItems).toHaveLength(0)
|
||||
})
|
||||
|
||||
describe('workflow switching during generation', () => {
|
||||
async function setup() {
|
||||
vi.useFakeTimers()
|
||||
const { nextTick } = await import('vue')
|
||||
const store = useLinearOutputStore()
|
||||
return { store, nextTick }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves images and latent previews across tab switch', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image + 1 latent
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onLatentPreview('job-a', 'blob:node2-latent', '2')
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
const imagesBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'image'
|
||||
)
|
||||
const latentsBefore = store.inProgressItems.filter(
|
||||
(i) => i.state === 'latent'
|
||||
)
|
||||
expect(imagesBefore).toHaveLength(1)
|
||||
expect(latentsBefore).toHaveLength(1)
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items still in store (not reset)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'image')).toEqual(
|
||||
imagesBefore
|
||||
)
|
||||
expect(store.inProgressItems.filter((i) => i.state === 'latent')).toEqual(
|
||||
latentsBefore
|
||||
)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Items visible via activeWorkflowInProgressItems
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(2)
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.some((i) => i.state === 'image')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('captures outputs produced while viewing another tab', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job, produce 1 image
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
|
||||
// Switch away
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: node 2 executes (event missed — listener removed)
|
||||
// Node 3 starts sending latent previews (watcher guarded)
|
||||
previewsRef.value = {
|
||||
'job-a': { url: 'blob:node3-latent', nodeId: '3' }
|
||||
}
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Original image preserved + latent preview recovered
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(1)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'latent')
|
||||
).toHaveLength(1)
|
||||
expect(store.inProgressItems[0].latentPreviewUrl).toBe(
|
||||
'blob:node3-latent'
|
||||
)
|
||||
})
|
||||
|
||||
it('scopes items to correct workflow after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start job
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow A items should NOT appear for workflow B
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
|
||||
// Workflow A items should reappear
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-a')
|
||||
expect(store.activeWorkflowInProgressItems[0].state).toBe('image')
|
||||
})
|
||||
|
||||
it('completes job A while away and starts tracking job B on return', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: start and partially generate
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch to workflow B (graph mode)
|
||||
activeWorkflowPathRef.value = 'workflows/graph-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// While away: job A finishes, job B starts
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// Switch back to workflow A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A should have been completed (pending resolve)
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Job B should have been started
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('handles job finishing while away with no new job', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Job finishes, no new job
|
||||
activeJobIdRef.value = null
|
||||
await nextTick()
|
||||
|
||||
// Switch back
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Job A completed, images pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
// No new skeleton should have been created (no active job)
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'skeleton')
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not leak workflow A items into workflow B view', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Workflow A: two images
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
store.onNodeExecuted(
|
||||
'job-a',
|
||||
makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'b.png', subfolder: '', type: 'output' }],
|
||||
'2'
|
||||
)
|
||||
)
|
||||
|
||||
// Items exist in the global list
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
|
||||
// Switch to workflow B (also app mode)
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
|
||||
// Workflow B should see nothing from job-a
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
|
||||
// Global list still has them
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('cleans up stale tracked job when leaving app mode after job finishes', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job finishes while still in app mode
|
||||
store.onJobComplete('job-a')
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Now switch away — pendingResolve items stay (still-running case
|
||||
// doesn't apply, but items are kept for history absorption)
|
||||
activeJobIdRef.value = null
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// Items preserved (pendingResolve is not cleared on exit)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-a')).toBe(true)
|
||||
})
|
||||
|
||||
it('evicts prior pendingResolve entries when a new job completes', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
// Job 1: produce image, complete
|
||||
setJobWorkflowPath('job-1', 'workflows/test-workflow.json')
|
||||
store.onJobStart('job-1')
|
||||
store.onNodeExecuted('job-1', makeExecutedDetail('job-1'))
|
||||
store.onJobComplete('job-1')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(true)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(true)
|
||||
|
||||
// Job 2: produce image, complete — should evict job-1
|
||||
store.onJobStart('job-2')
|
||||
store.onNodeExecuted('job-2', makeExecutedDetail('job-2'))
|
||||
store.onJobComplete('job-2')
|
||||
|
||||
expect(store.pendingResolve.has('job-1')).toBe(false)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-1')).toBe(false)
|
||||
// Job 2 is now pending resolve
|
||||
expect(store.pendingResolve.has('job-2')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cleans up finished tracked job on exit when job ended while in app mode', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Job ends, new job starts — all while in app mode
|
||||
setJobWorkflowPath('job-b', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-b'
|
||||
await nextTick()
|
||||
|
||||
// job-a completed via activeJobId watcher, now pending resolve
|
||||
expect(store.pendingResolve.has('job-a')).toBe(true)
|
||||
|
||||
// Switch away — tracked job is now job-b which is still active, so
|
||||
// the else-branch does NOT complete it (it's still running)
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
|
||||
// job-b items preserved (still running)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-b')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not leak items across many job cycles', () => {
|
||||
vi.useFakeTimers()
|
||||
const store = useLinearOutputStore()
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const jobId = `job-${i}`
|
||||
setJobWorkflowPath(jobId, 'workflows/test-workflow.json')
|
||||
store.onJobStart(jobId)
|
||||
store.onNodeExecuted(jobId, makeExecutedDetail(jobId))
|
||||
store.onJobComplete(jobId)
|
||||
}
|
||||
|
||||
// Only the last job should have items (pending resolve).
|
||||
// All prior jobs were evicted by subsequent onJobComplete calls.
|
||||
expect(store.pendingResolve.size).toBe(1)
|
||||
expect(store.pendingResolve.has('job-5')).toBe(true)
|
||||
expect(store.inProgressItems.every((i) => i.jobId === 'job-5')).toBe(true)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does not adopt another workflow job when switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Tab A: queue "cat"
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-cat', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted(
|
||||
'job-cat',
|
||||
makeExecutedDetail('job-cat', undefined, '1')
|
||||
)
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(1)
|
||||
expect(store.activeWorkflowInProgressItems[0].jobId).toBe('job-cat')
|
||||
|
||||
// Switch to tab B (different workflow, also app mode): queue "dog"
|
||||
activeWorkflowPathRef.value = 'workflows/app-b.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
setJobWorkflowPath('job-dog', 'workflows/app-b.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Tab B should see dog, not cat
|
||||
expect(
|
||||
store.activeWorkflowInProgressItems.every((i) => i.jobId === 'job-dog')
|
||||
).toBe(true)
|
||||
|
||||
// Switch back to tab A
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog's executed events arrive while viewing tab A (listener is
|
||||
// active and activeJobId is still job-dog).
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-dog',
|
||||
[{ filename: 'dog.png', subfolder: '', type: 'output' }],
|
||||
'1'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
// Dog's latent preview also arrives
|
||||
previewsRef.value = {
|
||||
'job-dog': { url: 'blob:dog-latent', nodeId: '2' }
|
||||
}
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
|
||||
// Tab A must NOT show dog — only cat
|
||||
const tabAItems = store.activeWorkflowInProgressItems
|
||||
expect(tabAItems.every((i) => i.jobId === 'job-cat')).toBe(true)
|
||||
expect(tabAItems.some((i) => i.jobId === 'job-dog')).toBe(false)
|
||||
|
||||
// Selection must not have been yanked to a dog item
|
||||
expect(store.selectedId?.includes('job-dog') ?? false).toBe(false)
|
||||
|
||||
// Dog should still exist globally (scoped to tab B)
|
||||
expect(store.inProgressItems.some((i) => i.jobId === 'job-dog')).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create skeleton for next job from another workflow', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
// Run dog on dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
setJobWorkflowPath('job-dog', 'workflows/dog.json')
|
||||
activeJobIdRef.value = 'job-dog'
|
||||
await nextTick()
|
||||
|
||||
// Swap to cat tab, queue cat (dog still running)
|
||||
activeWorkflowPathRef.value = 'workflows/cat.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
setJobWorkflowPath('job-cat', 'workflows/cat.json')
|
||||
|
||||
// Swap back to dog tab
|
||||
activeWorkflowPathRef.value = 'workflows/dog.json'
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Dog finishes, cat starts (activeJobId transitions on dog tab)
|
||||
activeJobIdRef.value = 'job-cat'
|
||||
await nextTick()
|
||||
|
||||
// Dog tab must NOT show cat's skeleton
|
||||
expect(store.activeWorkflowInProgressItems).toHaveLength(0)
|
||||
// No skeleton for cat should have been created at all
|
||||
expect(
|
||||
store.inProgressItems.some(
|
||||
(i) => i.jobId === 'job-cat' && i.state === 'skeleton'
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('processes new executed events after switching back', async () => {
|
||||
const { store, nextTick } = await setup()
|
||||
|
||||
activeWorkflowPathRef.value = 'workflows/app-a.json'
|
||||
setJobWorkflowPath('job-a', 'workflows/app-a.json')
|
||||
activeJobIdRef.value = 'job-a'
|
||||
await nextTick()
|
||||
|
||||
store.onNodeExecuted('job-a', makeExecutedDetail('job-a', undefined, '1'))
|
||||
|
||||
// Switch away and back
|
||||
isAppModeRef.value = false
|
||||
await nextTick()
|
||||
isAppModeRef.value = true
|
||||
await nextTick()
|
||||
|
||||
// Fire an executed event via the API — listener should be re-attached
|
||||
const event = new CustomEvent('executed', {
|
||||
detail: makeExecutedDetail(
|
||||
'job-a',
|
||||
[{ filename: 'c.png', subfolder: '', type: 'output' }],
|
||||
'3'
|
||||
)
|
||||
})
|
||||
apiTarget.dispatchEvent(event)
|
||||
|
||||
expect(
|
||||
store.inProgressItems.filter((i) => i.state === 'image')
|
||||
).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,6 +167,14 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
}
|
||||
|
||||
function onJobComplete(jobId: string) {
|
||||
// On any job complete, remove all pending resolve items.
|
||||
if (pendingResolve.value.size > 0) {
|
||||
for (const oldJobId of pendingResolve.value) {
|
||||
removeJobItems(oldJobId)
|
||||
}
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
@@ -226,11 +234,16 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
function isJobForActiveWorkflow(jobId: string): boolean {
|
||||
return (
|
||||
executionStore.jobIdToSessionWorkflowPath.get(jobId) ===
|
||||
workflowStore.activeWorkflow?.path
|
||||
)
|
||||
}
|
||||
|
||||
function autoSelect(slotId: string, jobId: string) {
|
||||
// Only auto-select if the job belongs to the active workflow
|
||||
const path = workflowStore.activeWorkflow?.path
|
||||
if (path && executionStore.jobIdToSessionWorkflowPath.get(jobId) !== path)
|
||||
return
|
||||
if (!isJobForActiveWorkflow(jobId)) return
|
||||
|
||||
const sel = selectedId.value
|
||||
if (!sel || sel.startsWith('slot:') || isFollowing.value) {
|
||||
@@ -249,20 +262,6 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
onNodeExecuted(jobId, detail)
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (raf) {
|
||||
cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
executedNodeIds.clear()
|
||||
inProgressItems.value = []
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
trackedJobId.value = null
|
||||
currentSkeletonId.value = null
|
||||
pendingResolve.value = new Set()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => executionStore.activeJobId,
|
||||
(jobId, oldJobId) => {
|
||||
@@ -270,7 +269,10 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
if (oldJobId && oldJobId !== jobId) {
|
||||
onJobComplete(oldJobId)
|
||||
}
|
||||
if (jobId) {
|
||||
// Start tracking only if the job belongs to this workflow.
|
||||
// Jobs from other workflows are picked up by reconcileOnEnter
|
||||
// when the user switches to that workflow's tab.
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
onJobStart(jobId)
|
||||
}
|
||||
}
|
||||
@@ -288,14 +290,66 @@ export const useLinearOutputStore = defineStore('linearOutput', () => {
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
function reconcileOnEnter() {
|
||||
// Complete any tracked job that finished while we were away.
|
||||
// The activeJobId watcher couldn't fire onJobComplete because
|
||||
// isAppMode was false at the time.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
// Start tracking the current job only if it belongs to this
|
||||
// workflow — otherwise we'd adopt another tab's job.
|
||||
if (
|
||||
executionStore.activeJobId &&
|
||||
trackedJobId.value !== executionStore.activeJobId &&
|
||||
isJobForActiveWorkflow(executionStore.activeJobId)
|
||||
) {
|
||||
onJobStart(executionStore.activeJobId)
|
||||
}
|
||||
|
||||
// Clear stale selection from another workflow's job.
|
||||
if (
|
||||
selectedId.value?.startsWith('slot:') &&
|
||||
trackedJobId.value &&
|
||||
!isJobForActiveWorkflow(trackedJobId.value)
|
||||
) {
|
||||
selectedId.value = null
|
||||
isFollowing.value = true
|
||||
}
|
||||
|
||||
// Re-apply the latest latent preview that may have arrived while
|
||||
// away, but only for a job belonging to the active workflow.
|
||||
const jobId = trackedJobId.value
|
||||
if (jobId && isJobForActiveWorkflow(jobId)) {
|
||||
const preview = jobPreviewStore.nodePreviewsByPromptId[jobId]
|
||||
if (preview) onLatentPreview(jobId, preview.url, preview.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupOnLeave() {
|
||||
// If the tracked job already finished (no longer the active job),
|
||||
// complete it now to clean up skeletons/latents. If it's still
|
||||
// running, preserve all items for tab switching.
|
||||
if (
|
||||
trackedJobId.value &&
|
||||
trackedJobId.value !== executionStore.activeJobId
|
||||
) {
|
||||
onJobComplete(trackedJobId.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
isAppMode,
|
||||
(active, wasActive) => {
|
||||
if (active) {
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
reconcileOnEnter()
|
||||
} else if (wasActive) {
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
reset()
|
||||
cleanupOnLeave()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
|
||||
Reference in New Issue
Block a user