feat: navigate to previously active tab when closing current tab (#6624)

When closing a tab, the UI now returns to the most recently active tab
instead of always going to the first/next tab. This matches standard
browser tab behavior and prevents accidental edits in the wrong
workflow. Implementation uses a lazy-cleanup history array (max 32
entries) that tracks tab activations and skips closed tabs when finding
the previous tab to switch to. Fixes #6599


https://github.com/user-attachments/assets/0bb87969-fd01-4e6b-96e8-c0f741f23ff8

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6624-feat-navigate-to-previously-active-tab-when-closing-current-tab-2a36d73d365081f5be95db51ff7a03f6)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2025-11-11 10:03:35 -08:00
committed by GitHub
parent 879cb8f1a8
commit 0be1da2041
3 changed files with 156 additions and 4 deletions

View File

@@ -215,9 +215,15 @@ export const useWorkflowService = () => {
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()
}
// If this is the active workflow, load the next workflow
// If this is the active workflow, load the most recent workflow from history
if (workflowStore.isActive(workflow)) {
await loadNextOpenedWorkflow()
const mostRecentWorkflow = workflowStore.getMostRecentWorkflow()
if (mostRecentWorkflow) {
await openWorkflow(mostRecentWorkflow)
} else {
// Fallback to next workflow if no history
await loadNextOpenedWorkflow()
}
}
await workflowStore.closeWorkflow(workflow)

View File

@@ -158,6 +158,7 @@ interface WorkflowStore {
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
getMostRecentWorkflow: () => ComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
@@ -201,6 +202,14 @@ interface WorkflowStore {
}
export const useWorkflowStore = defineStore('workflow', () => {
/**
* History of tab activations. Most recent at the end.
* Tracks the order in which tabs were activated to support "go to previous" behavior.
* Lazily cleaned on access.
*/
const tabActivationHistory = ref<string[]>([])
const MAX_HISTORY_SIZE = 32
/**
* Detach the workflow from the store. lightweight helper function.
* @param workflow The workflow to detach.
@@ -308,6 +317,18 @@ export const useWorkflowStore = defineStore('workflow', () => {
const loadedWorkflow = await workflow.load()
activeWorkflow.value = loadedWorkflow
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
// Track activation in history (move to end if already present)
const historyIndex = tabActivationHistory.value.indexOf(workflow.path)
if (historyIndex !== -1) {
tabActivationHistory.value.splice(historyIndex, 1)
}
tabActivationHistory.value.push(workflow.path)
// Trim history if too large
if (tabActivationHistory.value.length > MAX_HISTORY_SIZE) {
tabActivationHistory.value.shift()
}
return loadedWorkflow
}
@@ -405,6 +426,39 @@ export const useWorkflowStore = defineStore('workflow', () => {
return null
}
/**
* Get the most recently active workflow from history (excluding current).
* Lazily cleans invalid paths from history.
* @returns The most recent valid workflow or null if none found.
*/
const getMostRecentWorkflow = (): ComfyWorkflow | null => {
const currentPath = activeWorkflow.value?.path
const validPaths: string[] = []
// Scan backwards through history
for (let i = tabActivationHistory.value.length - 1; i >= 0; i--) {
const path = tabActivationHistory.value[i]
// Skip current workflow
if (path === currentPath) continue
// Check if workflow is still open
if (openWorkflowPathSet.value.has(path)) {
validPaths.unshift(path)
const workflow = workflowLookup.value[path]
if (workflow) {
// Lazy cleanup: keep only valid paths
tabActivationHistory.value = validPaths
return workflow
}
}
}
// Cleanup: no valid workflows found, clear history
tabActivationHistory.value = []
return null
}
const persistedWorkflows = computed(() =>
Array.from(workflows.value).filter(
(workflow) =>
@@ -714,6 +768,7 @@ export const useWorkflowStore = defineStore('workflow', () => {
isActive,
openWorkflows,
openedWorkflowIndexShift,
getMostRecentWorkflow,
openWorkflow,
openWorkflowsInBackground,
isOpen,

View File

@@ -3,9 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyWorkflow,
LoadedComfyWorkflow
} from '@/platform/workflow/management/stores/workflowStore'
import {
type LoadedComfyWorkflow,
useWorkflowBookmarkStore,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
@@ -723,4 +725,93 @@ describe('useWorkflowStore', () => {
})
})
})
describe('Tab Activation History', () => {
let workflowA: ComfyWorkflow
let workflowB: ComfyWorkflow
let workflowC: ComfyWorkflow
beforeEach(async () => {
await syncRemoteWorkflows(['a.json', 'b.json', 'c.json'])
workflowA = store.getWorkflowByPath('workflows/a.json')!
workflowB = store.getWorkflowByPath('workflows/b.json')!
workflowC = store.getWorkflowByPath('workflows/c.json')!
vi.mocked(api.getUserData).mockResolvedValue({
status: 200,
text: () => Promise.resolve(defaultGraphJSON)
} as Response)
})
it('should return most recently active workflow', async () => {
// Open workflows in order: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// C is current, B should be most recent
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowB.path)
})
it('should skip closed workflows (lazy cleanup)', async () => {
// Open workflows: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// Close B (the most recent before C)
await store.closeWorkflow(workflowB)
// C is current, B is closed, so A should be returned
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowA.path)
})
it('should return null when no valid history exists', async () => {
// Open only one workflow
await store.openWorkflow(workflowA)
// No previous workflows, should return null
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent).toBeNull()
})
it('should track history when opening workflows', async () => {
// Open A, then B, then A again
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowA)
// A is current, B should be most recent
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowB.path)
})
it('should handle workflow activated multiple times', async () => {
// Open: A -> B -> A -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowC)
// C is current, A should be most recent (not B)
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent?.path).toBe(workflowA.path)
})
it('should clean up history when all previous workflows are closed', async () => {
// Open: A -> B -> C
await store.openWorkflow(workflowA)
await store.openWorkflow(workflowB)
await store.openWorkflow(workflowC)
// Close A and B
await store.closeWorkflow(workflowA)
await store.closeWorkflow(workflowB)
// C is current, no valid history
const mostRecent = store.getMostRecentWorkflow()
expect(mostRecent).toBeNull()
})
})
})