diff --git a/src/renderer/extensions/linearMode/linearOutputStore.test.ts b/src/renderer/extensions/linearMode/linearOutputStore.test.ts index 85b31d3903..9fdb7c38ea 100644 --- a/src/renderer/extensions/linearMode/linearOutputStore.test.ts +++ b/src/renderer/extensions/linearMode/linearOutputStore.test.ts @@ -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) + }) + }) }) diff --git a/src/renderer/extensions/linearMode/linearOutputStore.ts b/src/renderer/extensions/linearMode/linearOutputStore.ts index 384685c4cc..eda92b00e7 100644 --- a/src/renderer/extensions/linearMode/linearOutputStore.ts +++ b/src/renderer/extensions/linearMode/linearOutputStore.ts @@ -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 }