mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 00:02:38 +00:00
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:
co-authored by
Neil
Wooseong Kim
Cursor
parent
752dbc9ef8
commit
8ccc336dcb
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user