mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 16:02:50 +00:00
fix(tabs): unify native chat command dispatch
This commit is contained in:
+7
-2
@@ -24,12 +24,16 @@ const store = vi.hoisted(() => ({
|
||||
reconcileWorktreeTabModel: vi.fn(() => ({ renderableTabCount: 1 })),
|
||||
setActiveWorktree: mocks.setActiveWorktree,
|
||||
tabsByWorktree: {},
|
||||
unifiedTabsByWorktree: {}
|
||||
unifiedTabsByWorktree: {} as Record<string, unknown[]>
|
||||
}))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return { ...actual, useCallback: <T>(callback: T) => callback }
|
||||
return {
|
||||
...actual,
|
||||
useCallback: <T>(callback: T) => callback,
|
||||
useMemo: <T>(factory: () => T) => factory()
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
@@ -105,6 +109,7 @@ const AGENT_TAB = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
store.unifiedTabsByWorktree = { 'wt-1': [AGENT_TAB] }
|
||||
mocks.closeStructuredAgentSession.mockResolvedValue('closed')
|
||||
mocks.callRuntimeRpc.mockResolvedValue({ ok: true })
|
||||
})
|
||||
|
||||
@@ -245,3 +245,45 @@ describe('closing a browser workspace owned by more than one runtime environment
|
||||
expect(mocks.destroyWorkspaceWebviews).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared tab close policies', () => {
|
||||
it('queues the dirty editor close without removing its tab or file', () => {
|
||||
const editor = {
|
||||
...BROWSER_TAB,
|
||||
id: 'editor-tab',
|
||||
entityId: 'dirty-file',
|
||||
contentType: 'editor'
|
||||
} as Tab
|
||||
useAppStore.setState({
|
||||
unifiedTabsByWorktree: { 'worktree-a': [editor] },
|
||||
openFiles: [{ id: 'dirty-file', isDirty: true, worktreeId: 'worktree-a' }]
|
||||
} as never)
|
||||
renderHook(() =>
|
||||
useTabGroupTabCloseCommands({ worktreeId: 'worktree-a', groupTabs: [editor] })
|
||||
).result.current.closeItem(editor.id)
|
||||
expect(mocks.requestEditorFileClose).toHaveBeenCalledWith('dirty-file')
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(useAppStore.getState().closeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shares terminal teardown while skipping running-process prompts only for bulk closes', () => {
|
||||
const terminal = {
|
||||
...BROWSER_TAB,
|
||||
id: 'terminal-tab',
|
||||
entityId: 'terminal-entity',
|
||||
contentType: 'terminal'
|
||||
} as Tab
|
||||
useAppStore.setState({ unifiedTabsByWorktree: { 'worktree-a': [terminal] } })
|
||||
const { result } = renderHook(() =>
|
||||
useTabGroupTabCloseCommands({ worktreeId: 'worktree-a', groupTabs: [terminal] })
|
||||
)
|
||||
result.current.closeItem(terminal.id)
|
||||
expect(mocks.closeTerminalTab).toHaveBeenLastCalledWith('terminal-entity', {
|
||||
onClosed: expect.any(Function)
|
||||
})
|
||||
result.current.closeMany([terminal.id])
|
||||
expect(mocks.closeTerminalTab).toHaveBeenLastCalledWith('terminal-entity', {
|
||||
skipRunningProcessConfirm: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useMemo } from 'react'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { destroyWorkspaceWebviews } from '../../store/slices/browser-webview-cleanup'
|
||||
import { requestEditorFileClose } from '../editor/editor-autosave'
|
||||
import { isWebRuntimeSessionActive } from '../../runtime/web-runtime-session'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close'
|
||||
import { cancelStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function reportStructuredSessionCloseError(error: unknown): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.structuredSessionCloseFailed',
|
||||
'Could not close this chat session'
|
||||
),
|
||||
{ description: error instanceof Error ? error.message : String(error) }
|
||||
)
|
||||
}
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
import { createWorkspaceTabCloseCommands } from './workspace-tab-close-commands'
|
||||
|
||||
export function useTabGroupTabCloseCommands({
|
||||
worktreeId,
|
||||
@@ -31,211 +10,27 @@ export function useTabGroupTabCloseCommands({
|
||||
worktreeId: string
|
||||
groupTabs: Tab[]
|
||||
}) {
|
||||
const closeUnifiedTab = useAppStore((state) => state.closeUnifiedTab)
|
||||
const closeFile = useAppStore((state) => state.closeFile)
|
||||
const closeBrowserTab = useAppStore((state) => state.closeBrowserTab)
|
||||
const setActiveWorktree = useAppStore((state) => state.setActiveWorktree)
|
||||
|
||||
const closeEditorIfUnreferenced = useCallback(
|
||||
(entityId: string, closingTabId: string) => {
|
||||
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(item) =>
|
||||
item.id !== closingTabId &&
|
||||
item.entityId === entityId &&
|
||||
(item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details')
|
||||
)
|
||||
if (!otherReference) {
|
||||
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
|
||||
if (file?.isDirty) {
|
||||
// Why: route through Terminal.tsx so the unsaved-confirmation save/discard queue stays centralized across all close paths.
|
||||
requestEditorFileClose(entityId)
|
||||
return false
|
||||
}
|
||||
closeFile(entityId)
|
||||
}
|
||||
return true
|
||||
},
|
||||
[closeFile, worktreeId]
|
||||
)
|
||||
|
||||
const leaveWorktreeIfEmpty = useCallback(() => {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: split-group closes bypass legacy Terminal.tsx; deselect the emptied worktree here or the window goes blank instead of landing.
|
||||
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
|
||||
if (renderableTabCount === 0) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
}, [setActiveWorktree, worktreeId])
|
||||
|
||||
const closeBrowserItem = useCallback(
|
||||
(item: Tab, focusedEnvironmentId: string | null | undefined) => {
|
||||
const state = useAppStore.getState()
|
||||
const plan = closeBrowserWorkspaceTabOnHosts({
|
||||
state,
|
||||
worktreeId,
|
||||
workspaceId: item.entityId,
|
||||
visibleTabId: item.id,
|
||||
focusedEnvironmentId
|
||||
})
|
||||
// Why: both teardown sites take the reason. closeBrowserTab usually removes the visible tab
|
||||
// itself, but when it cannot find it the bare call below is the one that runs — and a cleanup
|
||||
// close that lands there without these options hands the user an empty-worktree landing.
|
||||
const cleanupOptions =
|
||||
plan.localCloseReason === 'cleanup'
|
||||
? { preserveWorktreeSelection: true, recordInteraction: false }
|
||||
: undefined
|
||||
if (plan.closesLocally) {
|
||||
// Why before the teardown: closeBrowserTab announces the MRU page selection, and a guest
|
||||
// torn down first leaves the fallback picking registration order instead (#16306).
|
||||
closeBrowserTab(
|
||||
item.entityId,
|
||||
plan.localCloseReason ? { reason: plan.localCloseReason } : undefined
|
||||
)
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId)
|
||||
}
|
||||
if (plan.removesVisibleTab) {
|
||||
closeUnifiedTab(item.id, cleanupOptions)
|
||||
}
|
||||
return plan
|
||||
},
|
||||
[closeBrowserTab, closeUnifiedTab, worktreeId]
|
||||
)
|
||||
|
||||
const closeItem = useCallback(
|
||||
(itemId: string, opts?: { skipEmptyCheck?: boolean }) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
if (item.isPinned) {
|
||||
return
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
|
||||
useAppStore.getState(),
|
||||
worktreeId
|
||||
)
|
||||
if (item.contentType === 'agent-session') {
|
||||
cancelStructuredAgentLaunch(worktreeId, item.entityId)
|
||||
// Why: the structured session lives on the host, so the local tab close must also
|
||||
// retire the host's canonical row or it reappears on the next sync.
|
||||
// Cancel a still-reconciling create before closing its owner; otherwise a missing
|
||||
// post-create snapshot is mistaken for an unknown outcome and retried after close.
|
||||
cancelStructuredAgentLaunch(worktreeId, item.entityId)
|
||||
const target = getActiveRuntimeTarget({
|
||||
activeRuntimeEnvironmentId: runtimeEnvironmentId
|
||||
return useMemo(
|
||||
() => ({
|
||||
closeItem: (tabId: string, opts?: { skipEmptyCheck?: boolean }) => {
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId, tabId },
|
||||
...opts
|
||||
})
|
||||
void closeStructuredAgentSession(target, item.entityId)
|
||||
.then(() =>
|
||||
callRuntimeRpc(target, 'session.tabs.close', {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
tabId: `agent-session:${item.entityId}`,
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
closeUnifiedTab(item.id)
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
},
|
||||
closeMany: (tabIds: string[]) => {
|
||||
for (const tabId of tabIds) {
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId, tabId },
|
||||
bulk: true
|
||||
})
|
||||
.catch(reportStructuredSessionCloseError)
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'terminal') {
|
||||
// Why: closeTerminalTab can defer behind a pin / running-process dialog, so the
|
||||
// empty check has to run on the actual close — never on cancel.
|
||||
closeTerminalTab(
|
||||
item.entityId,
|
||||
opts?.skipEmptyCheck ? undefined : { onClosed: leaveWorktreeIfEmpty }
|
||||
)
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
const plan = closeBrowserItem(item, runtimeEnvironmentId)
|
||||
// Why: the empty check below answers "the user emptied this worktree". Unwinding a create
|
||||
// that never finished is not that — it must leave the selection as the click found it.
|
||||
if (!plan.closesLocally || plan.localCloseReason === 'cleanup') {
|
||||
return
|
||||
}
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (!canCloseTab) {
|
||||
return
|
||||
}
|
||||
closeUnifiedTab(item.id)
|
||||
}
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
},
|
||||
[
|
||||
closeBrowserItem,
|
||||
closeEditorIfUnreferenced,
|
||||
closeUnifiedTab,
|
||||
groupTabs,
|
||||
leaveWorktreeIfEmpty,
|
||||
worktreeId
|
||||
]
|
||||
},
|
||||
leaveWorktreeIfEmpty: createWorkspaceTabCloseCommands({ worktreeId, groupTabs })
|
||||
.leaveWorktreeIfEmpty
|
||||
}),
|
||||
[worktreeId, groupTabs]
|
||||
)
|
||||
|
||||
const closeMany = useCallback(
|
||||
(itemIds: string[]) => {
|
||||
for (const itemId of itemIds) {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item || item.isPinned) {
|
||||
continue
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
|
||||
useAppStore.getState(),
|
||||
worktreeId
|
||||
)
|
||||
if (item.contentType === 'agent-session') {
|
||||
cancelStructuredAgentLaunch(worktreeId, item.entityId)
|
||||
const target = getActiveRuntimeTarget({
|
||||
activeRuntimeEnvironmentId: runtimeEnvironmentId
|
||||
})
|
||||
void closeStructuredAgentSession(target, item.entityId)
|
||||
.then(() =>
|
||||
callRuntimeRpc(target, 'session.tabs.close', {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
tabId: `agent-session:${item.entityId}`,
|
||||
reason: 'user'
|
||||
})
|
||||
)
|
||||
.then(() => closeUnifiedTab(item.id))
|
||||
.catch(reportStructuredSessionCloseError)
|
||||
continue
|
||||
}
|
||||
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
// Why: revoke local resume + hook authority before the host removes its canonical tab.
|
||||
// No running-process prompt: a bulk close of N busy tabs would be a modal storm.
|
||||
closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true })
|
||||
continue
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
closeBrowserItem(item, runtimeEnvironmentId)
|
||||
} else if (item.contentType === 'terminal') {
|
||||
closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true })
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (canCloseTab) {
|
||||
closeUnifiedTab(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[closeBrowserItem, closeEditorIfUnreferenced, closeUnifiedTab, groupTabs, worktreeId]
|
||||
)
|
||||
|
||||
return { closeItem, closeMany, leaveWorktreeIfEmpty }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { toast } from 'sonner'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import { requestEditorFileClose } from '../editor/editor-autosave'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { closeWorkspaceBrowserTab } from '@/lib/workspace-browser-tab-close'
|
||||
import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { withLocalSessionTabCloseOwner } from '@/runtime/local-session-tab-close-owner'
|
||||
import { closeStructuredAgentSession } from '@/runtime/structured-agent-session-close'
|
||||
import { cancelStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
|
||||
import { toRuntimeWorktreeSelector } from '@/runtime/runtime-worktree-selector'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
function reportStructuredSessionCloseError(error: unknown): void {
|
||||
toast.error(
|
||||
translate(
|
||||
'components.native-chat.structuredSessionCloseFailed',
|
||||
'Could not close this chat session'
|
||||
),
|
||||
{ description: error instanceof Error ? error.message : String(error) }
|
||||
)
|
||||
}
|
||||
|
||||
export function createWorkspaceTabCloseCommands({
|
||||
worktreeId,
|
||||
groupTabs
|
||||
}: {
|
||||
worktreeId: string
|
||||
groupTabs: Tab[]
|
||||
}) {
|
||||
const { closeUnifiedTab, closeFile, setActiveWorktree } = useAppStore.getState()
|
||||
|
||||
const closeEditorIfUnreferenced = (entityId: string, closingTabId: string) => {
|
||||
const otherReference = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? []).some(
|
||||
(item) =>
|
||||
item.id !== closingTabId &&
|
||||
item.entityId === entityId &&
|
||||
(item.contentType === 'editor' ||
|
||||
item.contentType === 'diff' ||
|
||||
item.contentType === 'conflict-review' ||
|
||||
item.contentType === 'check-details')
|
||||
)
|
||||
if (!otherReference) {
|
||||
const file = useAppStore.getState().openFiles.find((candidate) => candidate.id === entityId)
|
||||
if (file?.isDirty) {
|
||||
// Why: route through Terminal.tsx so the unsaved-confirmation save/discard queue stays centralized across all close paths.
|
||||
requestEditorFileClose(entityId)
|
||||
return false
|
||||
}
|
||||
closeFile(entityId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const leaveWorktreeIfEmpty = () => {
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeWorktreeId !== worktreeId) {
|
||||
return
|
||||
}
|
||||
// Why: split-group closes bypass legacy Terminal.tsx; deselect the emptied worktree here or the window goes blank instead of landing.
|
||||
const { renderableTabCount } = state.reconcileWorktreeTabModel(worktreeId)
|
||||
if (renderableTabCount === 0) {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
}
|
||||
|
||||
const closeItem = (
|
||||
itemId: string,
|
||||
opts?: { skipEmptyCheck?: boolean; skipRunningProcessConfirm?: boolean }
|
||||
) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
|
||||
useAppStore.getState(),
|
||||
worktreeId
|
||||
)
|
||||
if (item.contentType === 'agent-session') {
|
||||
// Cancel pending creation and retire the host session before removing its tab.
|
||||
cancelStructuredAgentLaunch(worktreeId, item.entityId)
|
||||
const target = getActiveRuntimeTarget({
|
||||
activeRuntimeEnvironmentId: runtimeEnvironmentId
|
||||
})
|
||||
void closeStructuredAgentSession(target, item.entityId)
|
||||
.then(() => {
|
||||
const closeHostTab = () =>
|
||||
callRuntimeRpc(target, 'session.tabs.close', {
|
||||
worktree: toRuntimeWorktreeSelector(worktreeId),
|
||||
tabId: `agent-session:${item.entityId}`,
|
||||
reason: 'user'
|
||||
})
|
||||
return target.kind === 'local'
|
||||
? withLocalSessionTabCloseOwner(worktreeId, item.id, closeHostTab)
|
||||
: closeHostTab()
|
||||
})
|
||||
.then(() => {
|
||||
closeUnifiedTab(item.id)
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
})
|
||||
.catch(reportStructuredSessionCloseError)
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'terminal') {
|
||||
// Why: closeTerminalTab can defer behind a pin / running-process dialog, so the
|
||||
// empty check has to run on the actual close — never on cancel.
|
||||
closeTerminalTab(item.entityId, {
|
||||
...(opts?.skipRunningProcessConfirm ? { skipRunningProcessConfirm: true } : {}),
|
||||
...(!opts?.skipEmptyCheck ? { onClosed: leaveWorktreeIfEmpty } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
const plan = closeWorkspaceBrowserTab(worktreeId, item.entityId, item.id)
|
||||
// Why: the empty check below answers "the user emptied this worktree". Unwinding a create
|
||||
// that never finished is not that — it must leave the selection as the click found it.
|
||||
if (!plan.closesLocally || plan.localCloseReason === 'cleanup') {
|
||||
return
|
||||
}
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (!canCloseTab) {
|
||||
return
|
||||
}
|
||||
closeUnifiedTab(item.id)
|
||||
}
|
||||
if (!opts?.skipEmptyCheck) {
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
return { closeItem, leaveWorktreeIfEmpty }
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { handleSwitchTabAcrossAllTypes } from '../hooks/ipc-tab-switch'
|
||||
import { switchFloatingWorkspaceTab } from '@/lib/floating-workspace-terminal-actions'
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
import type { Tab } from '../../../shared/tab-types'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import {
|
||||
ORCA_EDITOR_REQUEST_CMD_SAVE_EVENT,
|
||||
@@ -11,6 +15,10 @@ import type { TerminalActivationController } from './use-terminal-activation-act
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
state: {} as Record<string, unknown>,
|
||||
closeTerminalTab: vi.fn(),
|
||||
closeStructuredAgentSession: vi.fn(async () => 'closed'),
|
||||
callRuntimeRpc: vi.fn(async () => ({ ok: true })),
|
||||
cancelStructuredAgentLaunch: vi.fn(),
|
||||
floatingFocused: false,
|
||||
targetInsideFloatingPanel: false
|
||||
}))
|
||||
@@ -27,6 +35,7 @@ vi.mock('@/lib/floating-workspace-terminal-actions', () => ({
|
||||
createFloatingWorkspaceMarkdownTab: vi.fn(),
|
||||
createFloatingWorkspaceTerminalTab: vi.fn(),
|
||||
handleEmptyFloatingWorkspacePanelCloseShortcut: () => false,
|
||||
isEmptyFloatingWorkspacePanelVisible: () => false,
|
||||
isEventTargetInsideFloatingWorkspacePanel: () => mocks.targetInsideFloatingPanel,
|
||||
isFloatingWorkspacePanelFocused: () => mocks.floatingFocused,
|
||||
switchFloatingWorkspaceTab: vi.fn()
|
||||
@@ -38,6 +47,26 @@ vi.mock('./terminal-agent-tab-shortcut', () => ({
|
||||
resolveTerminalAgentTabShortcut: () => ({ actionId: null, agent: null })
|
||||
}))
|
||||
|
||||
vi.mock('./terminal/terminal-tab-actions', () => ({ closeTerminalTab: mocks.closeTerminalTab }))
|
||||
vi.mock('@/runtime/structured-agent-session-close', () => ({
|
||||
closeStructuredAgentSession: mocks.closeStructuredAgentSession
|
||||
}))
|
||||
vi.mock('@/runtime/runtime-rpc-client', () => ({
|
||||
getActiveRuntimeTarget: () => ({ kind: 'local' }),
|
||||
callRuntimeRpc: mocks.callRuntimeRpc
|
||||
}))
|
||||
vi.mock('@/lib/structured-agent-session-launch', () => ({
|
||||
cancelStructuredAgentLaunch: mocks.cancelStructuredAgentLaunch
|
||||
}))
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({ getRuntimeEnvironmentIdForWorktree: () => null }))
|
||||
vi.mock('@/runtime/runtime-worktree-selector', () => ({
|
||||
toRuntimeWorktreeSelector: (id: string) => `id:${id}`
|
||||
}))
|
||||
vi.mock('@/runtime/browser-workspace-tab-close', () => ({
|
||||
closeBrowserWorkspaceTabOnHosts: () => ({ closesLocally: true, removesVisibleTab: true })
|
||||
}))
|
||||
vi.mock('../store/slices/browser-webview-cleanup', () => ({ destroyWorkspaceWebviews: vi.fn() }))
|
||||
|
||||
const controller = {
|
||||
activeWorktreeId: 'repo-1::/repo/worktree',
|
||||
handleCloseAllFiles: vi.fn(),
|
||||
@@ -102,3 +131,209 @@ describe('handleTerminalWorkspaceKeyDown editor.save', () => {
|
||||
expect(pressCmdS()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('tab.close uses the unified active tab', () => {
|
||||
const worktreeId = controller.activeWorktreeId!
|
||||
let tab: Tab
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const closeFile = vi.fn()
|
||||
const closeBrowserTab = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.floatingFocused = false
|
||||
mocks.targetInsideFloatingPanel = false
|
||||
tab = {
|
||||
id: 'chat-tab',
|
||||
entityId: 'chat-session',
|
||||
worktreeId,
|
||||
groupId: 'right-group',
|
||||
contentType: 'agent-session',
|
||||
label: 'Chat',
|
||||
customLabel: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
mocks.state = {
|
||||
activeView: 'terminal',
|
||||
activeWorktreeId: worktreeId,
|
||||
// The old terminal mirror can lag behind focus in a split group.
|
||||
activeTabType: 'terminal',
|
||||
activeTabId: 'other-terminal',
|
||||
activeFileId: 'other-file',
|
||||
activeBrowserTabId: 'other-browser',
|
||||
getActiveTab: () => tab,
|
||||
unifiedTabsByWorktree: { [worktreeId]: [tab] },
|
||||
openFiles: [],
|
||||
browserPagesByWorkspace: {},
|
||||
closeUnifiedTab,
|
||||
closeFile,
|
||||
closeBrowserTab,
|
||||
reconcileWorktreeTabModel: () => ({ renderableTabCount: 1 }),
|
||||
requestPinnedTabCloseConfirm: vi.fn(),
|
||||
setActiveWorktree: vi.fn()
|
||||
}
|
||||
})
|
||||
|
||||
function close(platform: NodeJS.Platform = 'darwin', terminalFocus = false): KeyboardEvent {
|
||||
const target = document.createElement('textarea')
|
||||
if (terminalFocus) {
|
||||
target.classList.add('xterm-helper-textarea')
|
||||
}
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'w',
|
||||
metaKey: platform === 'darwin',
|
||||
ctrlKey: platform !== 'darwin',
|
||||
cancelable: true
|
||||
})
|
||||
Object.defineProperty(event, 'target', { value: target })
|
||||
handleTerminalWorkspaceKeyDown(event, controller, platform)
|
||||
return event
|
||||
}
|
||||
|
||||
it.each(['darwin', 'win32', 'linux'] as const)(
|
||||
'closes native chat from its composer on %s',
|
||||
async (platform) => {
|
||||
expect(close(platform).defaultPrevented).toBe(true)
|
||||
await vi.waitFor(() => expect(closeUnifiedTab).toHaveBeenCalledWith(tab.id))
|
||||
expect(mocks.closeStructuredAgentSession).toHaveBeenCalledWith(
|
||||
{ kind: 'local' },
|
||||
'chat-session'
|
||||
)
|
||||
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith({ kind: 'local' }, 'session.tabs.close', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: 'agent-session:chat-session',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(mocks.cancelStructuredAgentLaunch).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it.each(['editor', 'diff', 'conflict-review', 'check-details', 'browser', 'simulator'] as const)(
|
||||
'closes the focused %s tab through the same command',
|
||||
(contentType) => {
|
||||
tab.contentType = contentType
|
||||
close()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith(
|
||||
tab.id,
|
||||
...(contentType === 'browser' ? [undefined] : [])
|
||||
)
|
||||
expect(controller.handleCloseFile).not.toHaveBeenCalled()
|
||||
expect(controller.handleCloseBrowserTab).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
|
||||
it('leaves a focused terminal split pane to its pane close handler', () => {
|
||||
tab.contentType = 'terminal'
|
||||
expect(close('darwin', true).defaultPrevented).toBe(false)
|
||||
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes a terminal tab from its native chat overlay through terminal teardown', () => {
|
||||
tab.contentType = 'terminal'
|
||||
tab.viewMode = 'chat'
|
||||
close()
|
||||
expect(mocks.closeTerminalTab).toHaveBeenCalledWith(tab.entityId, {
|
||||
onClosed: expect.any(Function)
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for pinned-tab confirmation before closing', async () => {
|
||||
tab.isPinned = true
|
||||
close()
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
const request = vi.mocked(mocks.state.requestPinnedTabCloseConfirm as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0]
|
||||
request.onConfirm()
|
||||
await vi.waitFor(() => expect(closeUnifiedTab).toHaveBeenCalledWith(tab.id))
|
||||
})
|
||||
|
||||
it('does not close the main workspace tab while the floating panel owns focus', () => {
|
||||
mocks.floatingFocused = true
|
||||
expect(close().defaultPrevented).toBe(false)
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves pinned tabs during a bulk close without prompting', () => {
|
||||
tab.isPinned = true
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId, tabId: tab.id },
|
||||
bulk: true
|
||||
})
|
||||
expect(mocks.state.requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not substitute the ambient tab for a stale explicit target', () => {
|
||||
expect(
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
target: { kind: 'tab', worktreeId, tabId: 'removed' }
|
||||
})
|
||||
).toBe(false)
|
||||
expect(mocks.closeStructuredAgentSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not close another group from an empty focused split', () => {
|
||||
mocks.state.getActiveTab = () => null
|
||||
mocks.state.activeGroupIdByWorktree = { [worktreeId]: 'empty-group' }
|
||||
mocks.state.groupsByWorktree = { [worktreeId]: [{ id: 'empty-group', activeTabId: null }] }
|
||||
tab.contentType = 'terminal'
|
||||
mocks.state.activeTabId = tab.entityId
|
||||
expect(close().defaultPrevented).toBe(false)
|
||||
expect(mocks.closeTerminalTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes only the focused copy of an editor shared between split groups', () => {
|
||||
tab.contentType = 'editor'
|
||||
mocks.state.unifiedTabsByWorktree = {
|
||||
[worktreeId]: [tab, { ...tab, id: 'left-copy', groupId: 'left-group' }]
|
||||
}
|
||||
close()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith(tab.id)
|
||||
expect(closeFile).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared tab navigation routing', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.floatingFocused = false
|
||||
mocks.targetInsideFloatingPanel = false
|
||||
mocks.state = { activeWorktreeId: controller.activeWorktreeId }
|
||||
})
|
||||
|
||||
function nextTab(): void {
|
||||
handleTerminalWorkspaceKeyDown(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: ']',
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
cancelable: true
|
||||
}),
|
||||
controller,
|
||||
'darwin'
|
||||
)
|
||||
}
|
||||
|
||||
it('sends the keyboard and IPC navigation intent to the same workspace operation', () => {
|
||||
nextTab()
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction: 1, scope: 'all-types' })
|
||||
expect(handleSwitchTabAcrossAllTypes).toHaveBeenCalledTimes(2)
|
||||
expect(handleSwitchTabAcrossAllTypes).toHaveBeenLastCalledWith(1)
|
||||
expect(switchFloatingWorkspaceTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes both entry points to the floating panel when it owns focus', () => {
|
||||
mocks.floatingFocused = true
|
||||
nextTab()
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction: 1, scope: 'all-types' })
|
||||
expect(switchFloatingWorkspaceTab).toHaveBeenCalledTimes(2)
|
||||
expect(switchFloatingWorkspaceTab).toHaveBeenLastCalledWith(mocks.state, 1, 'all-types')
|
||||
expect(handleSwitchTabAcrossAllTypes).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { toast } from 'sonner'
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
import type { KeybindingActionId } from '../../../shared/keybindings'
|
||||
import { keybindingMatchesAction } from '../../../shared/keybindings'
|
||||
import { matchesRecentTabSwitcherChord } from '../../../shared/window-shortcut-policy'
|
||||
import { useAppStore } from '../store'
|
||||
import {
|
||||
handleSwitchRecentTab,
|
||||
handleSwitchTab,
|
||||
handleSwitchTabAcrossAllTypes,
|
||||
handleSwitchTerminalTab
|
||||
} from '../hooks/ipc-tab-switch'
|
||||
import {
|
||||
createFloatingWorkspaceBrowserTab,
|
||||
createFloatingWorkspaceMarkdownTab,
|
||||
createFloatingWorkspaceTerminalTab,
|
||||
handleEmptyFloatingWorkspacePanelCloseShortcut,
|
||||
isEventTargetInsideFloatingWorkspacePanel,
|
||||
isFloatingWorkspacePanelFocused,
|
||||
switchFloatingWorkspaceTab
|
||||
isFloatingWorkspacePanelFocused
|
||||
} from '@/lib/floating-workspace-terminal-actions'
|
||||
import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification'
|
||||
import {
|
||||
@@ -38,8 +32,6 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
const {
|
||||
activeWorktreeId,
|
||||
handleCloseAllFiles,
|
||||
handleCloseBrowserTab,
|
||||
handleCloseFile,
|
||||
handleNewAgentTab,
|
||||
handleNewBrowserTab,
|
||||
handleNewFile,
|
||||
@@ -180,16 +172,9 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
if (floatingPanelOwnsEvent) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
if (state.activeTabType === 'terminal' && context === 'terminal') {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
notifyTerminalCapture('tab.close')
|
||||
if (state.activeTabType === 'editor' && state.activeFileId) {
|
||||
handleCloseFile(state.activeFileId)
|
||||
} else if (state.activeTabType === 'browser' && state.activeBrowserTabId) {
|
||||
handleCloseBrowserTab(state.activeBrowserTabId)
|
||||
if (dispatchWorkspaceTabCommand({ type: 'close', context })) {
|
||||
event.preventDefault()
|
||||
notifyTerminalCapture('tab.close')
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -211,7 +196,7 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
handleSwitchRecentTab()
|
||||
dispatchWorkspaceTabCommand({ type: 'previous-recent' })
|
||||
return
|
||||
}
|
||||
const switchSameTypeDirection = matchShortcut('tab.nextSameType')
|
||||
@@ -237,17 +222,11 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
? 'tab.nextSameType'
|
||||
: 'tab.previousSameType'
|
||||
)
|
||||
if (floatingWorkspaceFocused) {
|
||||
switchFloatingWorkspaceTab(
|
||||
useAppStore.getState(),
|
||||
switchAllTypesDirection ?? switchSameTypeDirection ?? 1,
|
||||
switchAllTypesDirection !== null ? 'all-types' : 'same-type'
|
||||
)
|
||||
} else if (switchAllTypesDirection !== null) {
|
||||
handleSwitchTabAcrossAllTypes(switchAllTypesDirection)
|
||||
} else {
|
||||
handleSwitchTab(switchSameTypeDirection ?? 1)
|
||||
}
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'switch',
|
||||
direction: switchAllTypesDirection ?? switchSameTypeDirection ?? 1,
|
||||
scope: switchAllTypesDirection !== null ? 'all-types' : 'same-type'
|
||||
})
|
||||
}
|
||||
const terminalTabDirection = matchShortcut('tab.nextTerminal')
|
||||
? 1
|
||||
@@ -258,10 +237,10 @@ export function handleTerminalWorkspaceKeyDown(
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
if (floatingWorkspaceFocused) {
|
||||
switchFloatingWorkspaceTab(useAppStore.getState(), terminalTabDirection, 'terminal')
|
||||
} else {
|
||||
handleSwitchTerminalTab(terminalTabDirection)
|
||||
}
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'switch',
|
||||
direction: terminalTabDirection,
|
||||
scope: 'terminal'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,95 +3,21 @@ import { useAppStore } from '../store'
|
||||
import { isProvenProcessExit } from '../../../shared/terminal-exit-cause'
|
||||
import { closeTerminalTab } from './terminal/terminal-tab-actions'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-pane/terminal-parked-tab-watchers'
|
||||
import { destroyWorkspaceWebviews } from '../store/slices/browser-webview-cleanup'
|
||||
import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close'
|
||||
import {
|
||||
getActiveWorktreeRuntimeEnvironmentId,
|
||||
isPinnedVisibleTab
|
||||
} from './terminal-workspace-model'
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
import type { TerminalCreateController } from './use-terminal-create-actions'
|
||||
|
||||
export function useTerminalCloseActions(controller: TerminalCreateController) {
|
||||
const {
|
||||
closeBrowserTab,
|
||||
consumeSuppressedPtyExit,
|
||||
setActiveFile,
|
||||
setActiveTab,
|
||||
setActiveTabType,
|
||||
setActiveWorktree
|
||||
} = controller
|
||||
const { consumeSuppressedPtyExit } = controller
|
||||
const handleCloseTab = useCallback((tabId: string) => {
|
||||
closeTerminalTab(tabId)
|
||||
}, [])
|
||||
|
||||
const handleCloseBrowserTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const state = useAppStore.getState()
|
||||
const owningWorktreeEntry = Object.entries(state.browserTabsByWorktree).find(
|
||||
([, worktreeTabs]) => worktreeTabs.some((tab) => tab.id === tabId)
|
||||
)
|
||||
const owningWorktreeId = owningWorktreeEntry?.[0] ?? null
|
||||
if (!owningWorktreeId) {
|
||||
return
|
||||
}
|
||||
if (isPinnedVisibleTab(state, owningWorktreeId, tabId)) {
|
||||
return
|
||||
}
|
||||
const plan = closeBrowserWorkspaceTabOnHosts({
|
||||
state,
|
||||
worktreeId: owningWorktreeId,
|
||||
workspaceId: tabId,
|
||||
visibleTabId: tabId,
|
||||
focusedEnvironmentId: getActiveWorktreeRuntimeEnvironmentId(owningWorktreeId)
|
||||
})
|
||||
if (!plan.closesLocally) {
|
||||
if (plan.removesVisibleTab) {
|
||||
const mirroredTab = (state.unifiedTabsByWorktree[owningWorktreeId] ?? []).find(
|
||||
(candidate) => candidate.contentType === 'browser' && candidate.entityId === tabId
|
||||
)
|
||||
if (mirroredTab) {
|
||||
state.closeUnifiedTab(mirroredTab.id)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const closeOptions = plan.localCloseReason ? { reason: plan.localCloseReason } : undefined
|
||||
const currentTabs = state.browserTabsByWorktree[owningWorktreeId] ?? []
|
||||
if (currentTabs.length <= 1) {
|
||||
const hasUnifiedEntry = Object.values(state.unifiedTabsByWorktree).some((tabs) =>
|
||||
tabs.some((tab) => tab.contentType === 'browser' && tab.entityId === tabId)
|
||||
)
|
||||
closeBrowserTab(tabId, closeOptions)
|
||||
// closeBrowserTab announces the MRU target before guest teardown can trigger bridge fallback.
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId)
|
||||
// Why: the fallback below answers "the user emptied this worktree". Unwinding a create
|
||||
// that never finished is not that, so it must leave the selection as the click found it.
|
||||
if (plan.localCloseReason === 'cleanup') {
|
||||
return
|
||||
}
|
||||
if (!hasUnifiedEntry && state.activeWorktreeId === owningWorktreeId) {
|
||||
const worktreeFile = state.openFiles.find((file) => file.worktreeId === owningWorktreeId)
|
||||
if (worktreeFile) {
|
||||
setActiveFile(worktreeFile.id)
|
||||
setActiveTabType('editor')
|
||||
} else {
|
||||
const terminalTab = (state.tabsByWorktree[owningWorktreeId] ?? [])[0]
|
||||
if (terminalTab) {
|
||||
setActiveTab(terminalTab.id)
|
||||
setActiveTabType('terminal')
|
||||
} else {
|
||||
setActiveWorktree(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
closeBrowserTab(tabId, closeOptions)
|
||||
// closeBrowserTab announces the MRU target before guest teardown can trigger bridge fallback.
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, tabId)
|
||||
},
|
||||
[closeBrowserTab, setActiveFile, setActiveTab, setActiveTabType, setActiveWorktree]
|
||||
)
|
||||
const handleCloseBrowserTab = useCallback((tabId: string) => {
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
target: { kind: 'browser-source', sourceId: tabId }
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePtyExit = useCallback(
|
||||
(tabId: string, ptyId: string, exitCode?: number) => {
|
||||
|
||||
@@ -5,11 +5,7 @@ import type { TerminalActivationController } from './use-terminal-activation-act
|
||||
export function useTerminalKeyboardShortcuts(controller: TerminalActivationController): void {
|
||||
const {
|
||||
activeWorktreeId,
|
||||
closeBrowserTab,
|
||||
handleCloseAllFiles,
|
||||
handleCloseBrowserTab,
|
||||
handleCloseFile,
|
||||
handleCloseTab,
|
||||
handleNewAgentTab,
|
||||
handleNewBrowserTab,
|
||||
handleNewFile,
|
||||
@@ -42,10 +38,6 @@ export function useTerminalKeyboardShortcuts(controller: TerminalActivationContr
|
||||
handleNewFile,
|
||||
handleNewTab,
|
||||
handleNewAgentTab,
|
||||
handleCloseTab,
|
||||
handleCloseBrowserTab,
|
||||
closeBrowserTab,
|
||||
handleCloseFile,
|
||||
handleCloseAllFiles,
|
||||
keybindings,
|
||||
mobileEmulatorEnabled,
|
||||
|
||||
@@ -53,6 +53,9 @@ export async function useIpcEventsForCloseRouting({
|
||||
respondTerminalTabClose?: ReturnType<typeof vi.fn>
|
||||
persistWorkspaceSession?: ReturnType<typeof vi.fn>
|
||||
}): Promise<void> {
|
||||
if (typeof HTMLElement === 'undefined') {
|
||||
vi.stubGlobal('HTMLElement', class {})
|
||||
}
|
||||
vi.doMock('react', async () => {
|
||||
const actual = await vi.importActual<typeof ReactModule>('react')
|
||||
return {
|
||||
@@ -67,6 +70,9 @@ export async function useIpcEventsForCloseRouting({
|
||||
useAppStore: {
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
getState: () => ({
|
||||
getActiveTab: () => null,
|
||||
closeUnifiedTab: vi.fn(),
|
||||
reconcileWorktreeTabModel: () => ({ renderableTabCount: 1 }),
|
||||
setUpdateStatus: vi.fn(),
|
||||
fetchRepos: vi.fn(),
|
||||
fetchWorktrees: vi.fn(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isLocalSessionTabCloseOwned } from '@/runtime/local-session-tab-close-owner'
|
||||
import { closeMobileSessionTabInStore } from '@/runtime/mobile-session-tab-close'
|
||||
import {
|
||||
SESSION_TAB_CLOSE_CANCELED_ERROR,
|
||||
@@ -16,6 +17,9 @@ import { resolveBrowserSessionTabTarget } from './browser-session-tab-target'
|
||||
export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void {
|
||||
unsubs.push(
|
||||
window.api.ui.onCloseSessionTab(({ tabId, worktreeId }) => {
|
||||
if (isLocalSessionTabCloseOwned(worktreeId, tabId)) {
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
const browserTarget = resolveBrowserSessionTabTarget(store, worktreeId, tabId)
|
||||
if (browserTarget) {
|
||||
@@ -80,6 +84,14 @@ export function registerSessionTabIpcBridge(unsubs: (() => void)[]): void {
|
||||
respond(error instanceof Error ? error.message : SESSION_TAB_CLOSE_FAILED_ERROR)
|
||||
}
|
||||
}
|
||||
if (isLocalSessionTabCloseOwned(worktreeId, tabId)) {
|
||||
respond(
|
||||
expiresAt !== undefined && Date.now() >= expiresAt
|
||||
? SESSION_TAB_CLOSE_TIMEOUT_ERROR
|
||||
: undefined
|
||||
)
|
||||
return
|
||||
}
|
||||
const visibleId = browserTarget?.workspaceId ?? tabId
|
||||
cancelConfirmation = guardPinnedTabClose({
|
||||
isPinned: isUnifiedTabPinned(store, worktreeId, visibleId),
|
||||
|
||||
@@ -4,20 +4,11 @@ import {
|
||||
createWebRuntimeSessionTerminal,
|
||||
isWebRuntimeSessionActive
|
||||
} from '@/runtime/web-runtime-session'
|
||||
import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close'
|
||||
import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup'
|
||||
import {
|
||||
guardPinnedTabClose,
|
||||
isUnifiedTabPinned,
|
||||
resolvePinnedTabLabel
|
||||
} from '../../store/pinned-tab-close-guard'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
|
||||
import { dispatchWorkspaceTabCommand } from '@/lib/workspace-tab-commands'
|
||||
import {
|
||||
createFloatingWorkspaceTerminalTab,
|
||||
isEmptyFloatingWorkspacePanelVisible,
|
||||
isFloatingWorkspacePanelFocused,
|
||||
resolveFloatingWorkspaceBrowserWorkspaceId,
|
||||
switchFloatingWorkspaceTab
|
||||
resolveFloatingWorkspaceBrowserWorkspaceId
|
||||
} from '@/lib/floating-workspace-terminal-actions'
|
||||
import {
|
||||
dispatchFloatingWorkspaceGuestClose,
|
||||
@@ -25,13 +16,6 @@ import {
|
||||
} from '@/lib/floating-workspace-guest-bridge'
|
||||
|
||||
import { useAppStore } from '../../store'
|
||||
import { resolveBrowserWorkspaceOwner } from '../../lib/browser-workspace-source-resolution'
|
||||
import {
|
||||
handleSwitchRecentTab,
|
||||
handleSwitchTab,
|
||||
handleSwitchTabAcrossAllTypes,
|
||||
handleSwitchTerminalTab
|
||||
} from '../ipc-tab-switch'
|
||||
function getWorktreeRuntimeEnvironmentId(worktreeId: string | null | undefined): string | null {
|
||||
return getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
}
|
||||
@@ -88,73 +72,12 @@ export function registerTabLifecycleIpcBridge(unsubs: (() => void)[]): void {
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onCloseActiveTab((payload) => {
|
||||
// Why: the empty-panel toggle is the ambient fallback only. A guest-originated close names a
|
||||
// main-workspace target, so an open-but-empty floating panel must not swallow it.
|
||||
if (!payload?.sourceId && isEmptyFloatingWorkspacePanelVisible()) {
|
||||
window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT))
|
||||
return
|
||||
}
|
||||
const store = useAppStore.getState()
|
||||
// Why: a guest-originated close names its own page; the activeTabType mirror goes stale in
|
||||
// split layouts (guest focus never reaches the group's focus-capture), so trust the source id.
|
||||
const explicitTarget = payload?.sourceId
|
||||
? resolveBrowserWorkspaceOwner(store, payload.sourceId)
|
||||
: null
|
||||
if (payload?.sourceId && !explicitTarget) {
|
||||
// Stale id (guest closed between keydown and IPC) = no-op, never the ambient fallback.
|
||||
return
|
||||
}
|
||||
if (explicitTarget || (store.activeTabType === 'browser' && store.activeBrowserTabId)) {
|
||||
const tabId = explicitTarget?.workspaceId ?? store.activeBrowserTabId
|
||||
const worktreeId = explicitTarget?.worktreeId ?? store.activeWorktreeId
|
||||
if (!tabId) {
|
||||
return
|
||||
}
|
||||
const closeActiveBrowserTab = (): void => {
|
||||
const currentStore = useAppStore.getState()
|
||||
if (!worktreeId) {
|
||||
currentStore.closeBrowserTab(tabId)
|
||||
return
|
||||
}
|
||||
// Why: the menu's Close Tab used to decide ownership itself — "runtime connected means
|
||||
// the host owns it" — which fires an inert close at a local-only or still-staged tab.
|
||||
// The shared plan is the one authority on who tears a browser workspace down.
|
||||
const plan = closeBrowserWorkspaceTabOnHosts({
|
||||
state: currentStore,
|
||||
worktreeId,
|
||||
workspaceId: tabId,
|
||||
visibleTabId: tabId,
|
||||
focusedEnvironmentId: getRuntimeEnvironmentIdForWorktree(currentStore, worktreeId)
|
||||
})
|
||||
if (plan.closesLocally) {
|
||||
// Why before the teardown: closeBrowserTab announces the MRU page selection, and a
|
||||
// guest torn down first leaves the fallback picking registration order (#16306).
|
||||
currentStore.closeBrowserTab(
|
||||
tabId,
|
||||
plan.localCloseReason ? { reason: plan.localCloseReason } : undefined
|
||||
)
|
||||
destroyWorkspaceWebviews(currentStore.browserPagesByWorkspace, tabId)
|
||||
return
|
||||
}
|
||||
if (plan.removesVisibleTab) {
|
||||
const mirroredTab = (currentStore.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.contentType === 'browser' && candidate.entityId === tabId
|
||||
)
|
||||
if (mirroredTab) {
|
||||
currentStore.closeUnifiedTab(mirroredTab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (worktreeId && isUnifiedTabPinned(store, worktreeId, tabId)) {
|
||||
guardPinnedTabClose({
|
||||
isPinned: true,
|
||||
tabLabel: resolvePinnedTabLabel(store, worktreeId, tabId),
|
||||
onClose: closeActiveBrowserTab
|
||||
})
|
||||
return
|
||||
}
|
||||
closeActiveBrowserTab()
|
||||
}
|
||||
dispatchWorkspaceTabCommand({
|
||||
type: 'close',
|
||||
...(payload?.sourceId
|
||||
? { target: { kind: 'browser-source', sourceId: payload.sourceId } as const }
|
||||
: {})
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
@@ -181,33 +104,16 @@ export function registerTabLifecycleIpcBridge(unsubs: (() => void)[]): void {
|
||||
|
||||
unsubs.push(
|
||||
window.api.ui.onSwitchTab((direction) => {
|
||||
const store = useAppStore.getState()
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
switchFloatingWorkspaceTab(store, direction, 'same-type')
|
||||
return
|
||||
}
|
||||
handleSwitchTab(direction)
|
||||
})
|
||||
)
|
||||
unsubs.push(
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction, scope: 'same-type' })
|
||||
}),
|
||||
window.api.ui.onSwitchTabAcrossAllTypes((direction) => {
|
||||
const store = useAppStore.getState()
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
switchFloatingWorkspaceTab(store, direction, 'all-types')
|
||||
return
|
||||
}
|
||||
handleSwitchTabAcrossAllTypes(direction)
|
||||
})
|
||||
)
|
||||
unsubs.push(window.api.ui.onSwitchRecentTab(handleSwitchRecentTab))
|
||||
unsubs.push(
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction, scope: 'all-types' })
|
||||
}),
|
||||
window.api.ui.onSwitchRecentTab(() => {
|
||||
dispatchWorkspaceTabCommand({ type: 'previous-recent' })
|
||||
}),
|
||||
window.api.ui.onSwitchTerminalTab((direction) => {
|
||||
const store = useAppStore.getState()
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
switchFloatingWorkspaceTab(store, direction, 'terminal')
|
||||
return
|
||||
}
|
||||
handleSwitchTerminalTab(direction)
|
||||
dispatchWorkspaceTabCommand({ type: 'switch', direction, scope: 'terminal' })
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('useIpcEvents Close Tab on the active browser tab', () => {
|
||||
|
||||
expect(closeWebRuntimeSessionTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'workspace-1',
|
||||
tabId: 'unified-1',
|
||||
environmentId: 'env-a',
|
||||
reason: 'user'
|
||||
})
|
||||
@@ -146,11 +146,11 @@ describe('useIpcEvents Close Tab on the active browser tab', () => {
|
||||
|
||||
expect(closeWebRuntimeSessionTab).toHaveBeenCalledWith({
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'workspace-1',
|
||||
tabId: 'unified-1',
|
||||
environmentId: 'env-a',
|
||||
reason: 'user'
|
||||
})
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-1')
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-1', undefined)
|
||||
expect(closeBrowserTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -181,7 +181,7 @@ describe('useIpcEvents Close Tab on the active browser tab', () => {
|
||||
requireListener(listenerRef)()
|
||||
|
||||
expect(closeWebRuntimeSessionTab).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(closeUnifiedTab).toHaveBeenCalledWith('unified-1', undefined)
|
||||
expect(closeBrowserTab).toHaveBeenCalledWith('workspace-1', undefined)
|
||||
})
|
||||
|
||||
|
||||
@@ -44,6 +44,39 @@ describe('useIpcEvents session tab close requests', () => {
|
||||
expect(respondSessionTabClose).toHaveBeenCalledWith({ requestId: 'close-session-tab' })
|
||||
})
|
||||
|
||||
it('acknowledges an initiating command without re-prompting or removing its tab early', async () => {
|
||||
const listenerRef: { current: SessionTabCloseRequestListener | null } = { current: null }
|
||||
const closeUnifiedTab = vi.fn()
|
||||
const respondSessionTabClose = vi.fn()
|
||||
const requestPinnedTabCloseConfirm = vi.fn()
|
||||
await useIpcEventsForCloseRouting({
|
||||
sessionTabCloseRequestListenerRef: listenerRef,
|
||||
respondSessionTabClose,
|
||||
getState: () => ({
|
||||
closeUnifiedTab,
|
||||
requestPinnedTabCloseConfirm,
|
||||
browserTabsByWorktree: {},
|
||||
openFiles: [],
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'chat-tab', entityId: 'session-1', contentType: 'agent-session', isPinned: true }
|
||||
]
|
||||
}
|
||||
})
|
||||
})
|
||||
const { withLocalSessionTabCloseOwner } =
|
||||
await import('@/runtime/local-session-tab-close-owner')
|
||||
await withLocalSessionTabCloseOwner('wt-1', 'chat-tab', async () => {
|
||||
listenerRef.current?.({ requestId: 'owned-close', worktreeId: 'wt-1', tabId: 'chat-tab' })
|
||||
expect(respondSessionTabClose).toHaveBeenCalledWith({ requestId: 'owned-close' })
|
||||
expect(closeUnifiedTab).not.toHaveBeenCalled()
|
||||
expect(requestPinnedTabCloseConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
listenerRef.current?.({ requestId: 'independent-close', worktreeId: 'wt-1', tabId: 'chat-tab' })
|
||||
expect(requestPinnedTabCloseConfirm).toHaveBeenCalledOnce()
|
||||
expect(respondSessionTabClose).not.toHaveBeenCalledWith({ requestId: 'independent-close' })
|
||||
})
|
||||
|
||||
it('rejects a pinned browser close when confirmation is canceled', async () => {
|
||||
const listenerRef: { current: SessionTabCloseRequestListener | null } = { current: null }
|
||||
const closeBrowserTab = vi.fn()
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from './worktree-runtime-owner'
|
||||
import { closeBrowserWorkspaceTabOnHosts } from '@/runtime/browser-workspace-tab-close'
|
||||
|
||||
export function closeWorkspaceBrowserTab(worktreeId: string, workspaceId: string, tabId?: string) {
|
||||
const state = useAppStore.getState()
|
||||
const { closeBrowserTab, closeUnifiedTab } = state
|
||||
const plan = closeBrowserWorkspaceTabOnHosts({
|
||||
state,
|
||||
worktreeId,
|
||||
workspaceId,
|
||||
visibleTabId: tabId ?? workspaceId,
|
||||
focusedEnvironmentId: getRuntimeEnvironmentIdForWorktree(state, worktreeId)
|
||||
})
|
||||
// Cleanup closes must preserve workspace selection at both teardown sites.
|
||||
const cleanupOptions =
|
||||
plan.localCloseReason === 'cleanup'
|
||||
? { preserveWorktreeSelection: true, recordInteraction: false }
|
||||
: undefined
|
||||
if (plan.closesLocally) {
|
||||
// Announce the MRU page selection before guest teardown triggers focus fallback.
|
||||
closeBrowserTab(
|
||||
workspaceId,
|
||||
plan.localCloseReason ? { reason: plan.localCloseReason } : undefined
|
||||
)
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, workspaceId)
|
||||
}
|
||||
if (plan.removesVisibleTab && tabId) {
|
||||
closeUnifiedTab(tabId, cleanupOptions)
|
||||
}
|
||||
return plan
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { KeybindingContext } from '../../../shared/keybindings'
|
||||
import { toVisibleTabType, type Tab } from '../../../shared/tab-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
|
||||
import { createWorkspaceTabCloseCommands } from '@/components/tab-group/workspace-tab-close-commands'
|
||||
import {
|
||||
handleSwitchRecentTab,
|
||||
handleSwitchTab,
|
||||
handleSwitchTabAcrossAllTypes,
|
||||
handleSwitchTerminalTab
|
||||
} from '@/hooks/ipc-tab-switch'
|
||||
import {
|
||||
isEmptyFloatingWorkspacePanelVisible,
|
||||
isFloatingWorkspacePanelFocused,
|
||||
switchFloatingWorkspaceTab
|
||||
} from './floating-workspace-terminal-actions'
|
||||
import { TOGGLE_FLOATING_TERMINAL_EVENT } from './floating-terminal'
|
||||
import { closeWorkspaceBrowserTab } from './workspace-browser-tab-close'
|
||||
import { resolveBrowserWorkspaceOwner } from './browser-workspace-source-resolution'
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: 'tab'; worktreeId: string; tabId: string }
|
||||
| { kind: 'browser-source'; sourceId: string }
|
||||
|
||||
export type WorkspaceTabCommand =
|
||||
| {
|
||||
type: 'close'
|
||||
target?: WorkspaceTabTarget
|
||||
context?: KeybindingContext
|
||||
skipEmptyCheck?: boolean
|
||||
bulk?: boolean
|
||||
}
|
||||
| { type: 'switch'; direction: number; scope: 'same-type' | 'all-types' | 'terminal' }
|
||||
| { type: 'previous-recent' }
|
||||
|
||||
type TabState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function hasFocusedGroup(state: TabState, worktreeId: string): boolean {
|
||||
const groupId = state.activeGroupIdByWorktree?.[worktreeId]
|
||||
return (state.groupsByWorktree?.[worktreeId] ?? []).some((group) => group.id === groupId)
|
||||
}
|
||||
|
||||
function resolveActiveTab(state: TabState, worktreeId: string): Tab | null {
|
||||
const activeTab = state.getActiveTab(worktreeId)
|
||||
if (activeTab) {
|
||||
return activeTab
|
||||
}
|
||||
if (hasFocusedGroup(state, worktreeId)) {
|
||||
return null
|
||||
}
|
||||
// Hydration can publish the backing selection before its group selection.
|
||||
const entityId =
|
||||
state.activeTabType === 'browser'
|
||||
? state.activeBrowserTabId
|
||||
: state.activeTabType === 'editor'
|
||||
? state.activeFileId
|
||||
: state.activeTabId
|
||||
return (
|
||||
(state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(tab) =>
|
||||
toVisibleTabType(tab.contentType) === state.activeTabType && tab.entityId === entityId
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
function resolveCloseTarget(
|
||||
state: TabState,
|
||||
target?: WorkspaceTabTarget
|
||||
): { worktreeId: string; tab: Tab | null; browserWorkspaceId?: string } | null {
|
||||
if (target?.kind === 'tab') {
|
||||
const tab = (state.unifiedTabsByWorktree[target.worktreeId] ?? []).find(
|
||||
(tab) => tab.id === target.tabId
|
||||
)
|
||||
return tab ? { worktreeId: target.worktreeId, tab } : null
|
||||
}
|
||||
if (target?.kind === 'browser-source') {
|
||||
const owner = resolveBrowserWorkspaceOwner(state, target.sourceId)
|
||||
if (!owner) {
|
||||
return null
|
||||
}
|
||||
const tab = (state.unifiedTabsByWorktree[owner.worktreeId] ?? []).find(
|
||||
(tab) => tab.contentType === 'browser' && tab.entityId === owner.workspaceId
|
||||
)
|
||||
return { worktreeId: owner.worktreeId, tab: tab ?? null, browserWorkspaceId: owner.workspaceId }
|
||||
}
|
||||
if (!state.activeWorktreeId) {
|
||||
return null
|
||||
}
|
||||
const tab = resolveActiveTab(state, state.activeWorktreeId)
|
||||
if (tab) {
|
||||
return { worktreeId: state.activeWorktreeId, tab }
|
||||
}
|
||||
if (
|
||||
!hasFocusedGroup(state, state.activeWorktreeId) &&
|
||||
state.activeTabType === 'browser' &&
|
||||
state.activeBrowserTabId
|
||||
) {
|
||||
return resolveCloseTarget(state, { kind: 'browser-source', sourceId: state.activeBrowserTabId })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Input adapters describe intent; targeting and tab operations live here. */
|
||||
export function dispatchWorkspaceTabCommand(command: WorkspaceTabCommand): boolean {
|
||||
const state = useAppStore.getState()
|
||||
if (command.type === 'close') {
|
||||
if (!command.target) {
|
||||
if (isEmptyFloatingWorkspacePanelVisible()) {
|
||||
window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT))
|
||||
return true
|
||||
}
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const target = resolveCloseTarget(state, command.target)
|
||||
if (!target) {
|
||||
return false
|
||||
}
|
||||
if (!target.tab) {
|
||||
if (!target.browserWorkspaceId) {
|
||||
return false
|
||||
}
|
||||
const plan = closeWorkspaceBrowserTab(target.worktreeId, target.browserWorkspaceId)
|
||||
if (
|
||||
plan.closesLocally &&
|
||||
plan.localCloseReason !== 'cleanup' &&
|
||||
!command.skipEmptyCheck &&
|
||||
!command.bulk
|
||||
) {
|
||||
createWorkspaceTabCloseCommands({
|
||||
worktreeId: target.worktreeId,
|
||||
groupTabs: []
|
||||
}).leaveWorktreeIfEmpty()
|
||||
}
|
||||
return true
|
||||
}
|
||||
const tab = target.tab
|
||||
if (command.context === 'terminal' && tab.contentType === 'terminal') {
|
||||
return false
|
||||
}
|
||||
const commands = createWorkspaceTabCloseCommands({
|
||||
worktreeId: target.worktreeId,
|
||||
groupTabs: state.unifiedTabsByWorktree[target.worktreeId] ?? []
|
||||
})
|
||||
if ((command.bulk || command.skipEmptyCheck) && tab.isPinned) {
|
||||
return true
|
||||
}
|
||||
const close = () =>
|
||||
commands.closeItem(tab.id, {
|
||||
skipEmptyCheck: command.bulk || command.skipEmptyCheck,
|
||||
skipRunningProcessConfirm: command.bulk
|
||||
})
|
||||
if (tab.contentType === 'terminal' || command.bulk) {
|
||||
close()
|
||||
} else {
|
||||
guardPinnedTabClose({
|
||||
isPinned: tab.isPinned === true,
|
||||
tabLabel: resolvePinnedTabLabel(state, target.worktreeId, tab.id),
|
||||
onClose: close
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (command.type === 'previous-recent') {
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
return false
|
||||
}
|
||||
return handleSwitchRecentTab()
|
||||
}
|
||||
if (isFloatingWorkspacePanelFocused()) {
|
||||
switchFloatingWorkspaceTab(state, command.direction, command.scope)
|
||||
return true
|
||||
}
|
||||
switch (command.scope) {
|
||||
case 'same-type':
|
||||
return handleSwitchTab(command.direction)
|
||||
case 'all-types':
|
||||
return handleSwitchTabAcrossAllTypes(command.direction)
|
||||
case 'terminal':
|
||||
return handleSwitchTerminalTab(command.direction)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isLocalSessionTabCloseOwned,
|
||||
withLocalSessionTabCloseOwner
|
||||
} from './local-session-tab-close-owner'
|
||||
|
||||
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('local session tab close ownership', () => {
|
||||
it('scopes ownership to the exact workspace and tab until host acknowledgement', async () => {
|
||||
const host = deferred()
|
||||
const closing = withLocalSessionTabCloseOwner('folder:one', 'chat', () => host.promise)
|
||||
expect(isLocalSessionTabCloseOwned('folder:one', 'chat')).toBe(true)
|
||||
expect(isLocalSessionTabCloseOwned('folder:two', 'chat')).toBe(false)
|
||||
expect(isLocalSessionTabCloseOwned('folder:one', 'other-chat')).toBe(false)
|
||||
host.resolve()
|
||||
await closing
|
||||
expect(isLocalSessionTabCloseOwned('folder:one', 'chat')).toBe(false)
|
||||
})
|
||||
|
||||
it('releases ownership on host failure so a later close must be authorized again', async () => {
|
||||
await expect(
|
||||
withLocalSessionTabCloseOwner('wt', 'chat', async () => {
|
||||
throw new Error('host unavailable')
|
||||
})
|
||||
).rejects.toThrow('host unavailable')
|
||||
expect(isLocalSessionTabCloseOwned('wt', 'chat')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps overlapping handoffs owned until both have settled', async () => {
|
||||
const first = deferred()
|
||||
const second = deferred()
|
||||
const firstClose = withLocalSessionTabCloseOwner('wt', 'chat', () => first.promise)
|
||||
const secondClose = withLocalSessionTabCloseOwner('wt', 'chat', () => second.promise)
|
||||
first.resolve()
|
||||
await firstClose
|
||||
expect(isLocalSessionTabCloseOwned('wt', 'chat')).toBe(true)
|
||||
second.resolve()
|
||||
await secondClose
|
||||
expect(isLocalSessionTabCloseOwned('wt', 'chat')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
const owners = new Map<string, number>()
|
||||
|
||||
function closeKey(worktreeId: string, tabId: string): string {
|
||||
return JSON.stringify([worktreeId, tabId])
|
||||
}
|
||||
|
||||
export function isLocalSessionTabCloseOwned(worktreeId: string, tabId: string): boolean {
|
||||
return owners.has(closeKey(worktreeId, tabId))
|
||||
}
|
||||
|
||||
/** The initiating command owns UI removal; its local host relay only acknowledges the handoff. */
|
||||
export async function withLocalSessionTabCloseOwner<T>(
|
||||
worktreeId: string,
|
||||
tabId: string,
|
||||
close: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const key = closeKey(worktreeId, tabId)
|
||||
owners.set(key, (owners.get(key) ?? 0) + 1)
|
||||
try {
|
||||
return await close()
|
||||
} finally {
|
||||
const remaining = (owners.get(key) ?? 1) - 1
|
||||
if (remaining === 0) {
|
||||
owners.delete(key)
|
||||
} else {
|
||||
owners.set(key, remaining)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user