fix(editor): keep chat visible after closing Markdown tabs

Count remaining unified chat tabs before clearing workspace selection during editor close.

Related: https://github.com/stablyai/orca/pull/24273, https://github.com/stablyai/orca/pull/23760

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Wooseong Kim <innocarpe@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Neil
2026-10-03 00:52:10 -07:00
committed by GitHub
co-authored by Neil Wooseong Kim Cursor
parent 752dbc9ef8
commit 8ccc336dcb
4 changed files with 144 additions and 4 deletions
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from 'vitest'
import { createEditorTabsStore } from './editor-slice-test-harness'
vi.mock('@/runtime/close-mirrored-editor-tab', () => ({
notifyHostOfMirroredEditorClose: vi.fn()
}))
const closeActions = ['closeFile', 'closeAllFiles'] as const
const workspaces = [
{ label: 'git worktree', id: 'wt-1' },
{ label: 'folder workspace', id: 'folder:folder-1' }
] as const
function openMarkdown(store: ReturnType<typeof createEditorTabsStore>, worktreeId: string) {
return store.getState().openFile({
filePath: '/repo/notes.md',
relativePath: 'notes.md',
worktreeId,
language: 'markdown',
mode: 'edit'
})
}
describe.each(workspaces)('editor close selection in a $label', ({ id: worktreeId }) => {
it.each(closeActions)('%s reveals the surviving structured chat', (action) => {
const store = createEditorTabsStore()
store.setState({ activeWorktreeId: worktreeId })
const chat = store.getState().createUnifiedTab(worktreeId, 'agent-session', {
entityId: 'session-1',
agentSessionAgent: 'codex',
label: 'Codex Chat'
})
const fileId = openMarkdown(store, worktreeId)
if (action === 'closeFile') {
store.getState().closeFile(fileId)
} else {
store.getState().openFile({
filePath: '/repo/second.md',
relativePath: 'second.md',
worktreeId,
language: 'markdown',
mode: 'edit'
})
store.getState().closeAllFiles()
}
const state = store.getState()
expect(state.activeWorktreeId).toBe(worktreeId)
expect(state.activeTabType).toBe('agent-session')
expect(state.activeTabTypeByWorktree[worktreeId]).toBe('agent-session')
expect(state.unifiedTabsByWorktree[worktreeId]).toEqual([chat])
expect(state.groupsByWorktree[worktreeId][0].activeTabId).toBe(chat.id)
expect(state.openFiles).toEqual([])
expect(state.activeFileId).toBeNull()
})
it.each(closeActions)('%s returns to the welcome screen when no tab survives', (action) => {
const store = createEditorTabsStore()
store.setState({ activeWorktreeId: worktreeId })
const fileId = openMarkdown(store, worktreeId)
if (action === 'closeFile') {
store.getState().closeFile(fileId)
} else {
store.getState().closeAllFiles()
}
expect(store.getState().activeWorktreeId).toBeNull()
expect(store.getState().unifiedTabsByWorktree[worktreeId]).toEqual([])
})
})
describe('editor close mixed tab history', () => {
it.each(closeActions)('%s reveals the most recently used remaining chat', (action) => {
const store = createEditorTabsStore()
const first = store.getState().createUnifiedTab('wt-1', 'agent-session', {
entityId: 'session-1',
agentSessionAgent: 'codex'
})
const second = store.getState().createUnifiedTab('wt-1', 'agent-session', {
entityId: 'session-2',
agentSessionAgent: 'codex'
})
store.getState().activateTab(first.id)
const fileId = openMarkdown(store, 'wt-1')
if (action === 'closeFile') {
store.getState().closeFile(fileId)
} else {
store.getState().closeAllFiles()
}
const state = store.getState()
expect(state.activeWorktreeId).toBe('wt-1')
expect(state.activeTabType).toBe('agent-session')
expect(state.groupsByWorktree['wt-1'][0].activeTabId).toBe(first.id)
expect(state.unifiedTabsByWorktree['wt-1'].map((tab) => tab.id)).toEqual([first.id, second.id])
})
it.each(closeActions)('%s ignores chats in another workspace', (action) => {
const store = createEditorTabsStore()
const otherChat = store.getState().createUnifiedTab('wt-2', 'agent-session', {
entityId: 'session-2',
agentSessionAgent: 'codex',
activate: false
})
const fileId = openMarkdown(store, 'wt-1')
if (action === 'closeFile') {
store.getState().closeFile(fileId)
} else {
store.getState().closeAllFiles()
}
expect(store.getState().activeWorktreeId).toBeNull()
expect(store.getState().unifiedTabsByWorktree['wt-2']).toEqual([otherChat])
})
})
@@ -8,6 +8,7 @@ import {
deleteUntouchedUntitledFile,
shouldDeleteUntouchedUntitledFile
} from '../tabs/untitled-file-cleanup'
import { unifiedTabsKeepWorktreeSelected } from './unified-tabs-keep-worktree-selected'
export function createCloseFileAction(
set: EditorSet,
@@ -106,11 +107,19 @@ export function createCloseFileAction(
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
}
// Structured chats have no legacy terminal row to keep their workspace selected.
const hasRemainingUnifiedTabs =
activeWorktreeId !== null &&
unifiedTabsKeepWorktreeSelected(
s.unifiedTabsByWorktree?.[activeWorktreeId],
new Set([fileId])
)
const shouldDeactivateWorktree =
activeWorktreeId !== null &&
remainingForWorktree.length === 0 &&
browserTabsForWorktree.length === 0 &&
terminalTabsForWorktree.length === 0
terminalTabsForWorktree.length === 0 &&
!hasRemainingUnifiedTabs
// Why: prune the closed id from tabBarOrderByWorktree so stale ids don't shift positions on the next reconcile.
const worktreeId = closedFile?.worktreeId ?? activeWorktreeId
@@ -11,6 +11,7 @@ import {
deleteUntouchedUntitledFile,
shouldDeleteUntouchedUntitledFile
} from '../tabs/untitled-file-cleanup'
import { unifiedTabsKeepWorktreeSelected } from './unified-tabs-keep-worktree-selected'
export function createRecentlyClosedEditorTabs(
set: EditorSet,
@@ -122,14 +123,18 @@ export function createRecentlyClosedEditorTabs(
const terminalTabsForWorktree = s.tabsByWorktree[activeWorktreeId] ?? []
newActiveTabTypeByWorktree[activeWorktreeId] =
browserTabsForWorktree.length > 0 ? 'browser' : 'terminal'
const shouldDeactivateWorktree =
browserTabsForWorktree.length === 0 && terminalTabsForWorktree.length === 0
// Why: mirrored tabs use host tab ids in tab order while local entries use file ids; remove both shapes.
const closedFileIds = new Set(
s.openFiles.filter((f) => f.worktreeId === activeWorktreeId).map((f) => f.id)
)
const closedTabOrderIds = new Set([...closedFileIds, ...closingItemIds])
const shouldDeactivateWorktree =
browserTabsForWorktree.length === 0 &&
terminalTabsForWorktree.length === 0 &&
!unifiedTabsKeepWorktreeSelected(
s.unifiedTabsByWorktree?.[activeWorktreeId],
closedTabOrderIds
)
const nextTabBarOrderByWorktree = s.tabBarOrderByWorktree
? {
...s.tabBarOrderByWorktree,
@@ -0,0 +1,7 @@
/** True when a non-closed unified tab (for example a structured chat) still belongs to the worktree. */
export function unifiedTabsKeepWorktreeSelected(
tabs: readonly { id: string; entityId: string }[] | undefined,
closedIds: ReadonlySet<string>
): boolean {
return (tabs ?? []).some((tab) => !closedIds.has(tab.id) && !closedIds.has(tab.entityId))
}