mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
feat(terminal): ask before bulk-closing tabs with running work
Close Others / Close Tabs To The Right / Close Tabs To The Left / Close split pane closed busy terminals silently: every bulk path opted out of the running-process prompt because one modal per busy tab would have been a modal storm. Add a bulk guard that probes every terminal in the set in parallel and raises a single aggregated confirmation naming each tab that reported a live child. Idle sets stay synchronous, so nothing changes when there is nothing running. Cancel abandons the whole close rather than leaving a partial, un-undoable result; an unanswered probe asks instead of closing, matching the single-tab guard's fail-safe.
This commit is contained in:
@@ -182,6 +182,10 @@ import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut
|
||||
import { useContextualTour } from './contextual-tours/use-contextual-tour'
|
||||
import { openTabBarEntry, type TabCreateEntryArgs } from './tab-bar/tab-create-entry-action'
|
||||
import { closeTerminalTab } from './terminal/terminal-tab-actions'
|
||||
import {
|
||||
collectBulkTerminalTabIds,
|
||||
guardBulkTerminalClose
|
||||
} from './terminal/bulk-terminal-close-guard'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host'
|
||||
@@ -1835,61 +1839,69 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
const dirtyFileIds: string[] = []
|
||||
for (const id of tabIds) {
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[activeWorktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === id || candidate.entityId === id
|
||||
)
|
||||
if (unifiedTab?.isPinned) {
|
||||
continue
|
||||
}
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(activeWorktreeId)
|
||||
if (
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(unifiedTab?.contentType === 'terminal' ||
|
||||
(unifiedTab?.contentType === 'browser' &&
|
||||
browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId)))
|
||||
) {
|
||||
if (unifiedTab.contentType === 'terminal') {
|
||||
// Why: paired-host bulk close must revoke renderer resume and hook authority, not just remove the host session tab.
|
||||
// No running-process prompt: "Close Others" over N busy tabs would be a modal storm.
|
||||
closeTerminalTab(unifiedTab.entityId, { skipRunningProcessConfirm: true })
|
||||
} else {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((state.tabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)) {
|
||||
closeTab(id)
|
||||
} else if (
|
||||
state.openFiles.some((file) => file.worktreeId === activeWorktreeId && file.id === id)
|
||||
) {
|
||||
const file = state.openFiles.find((candidate) => candidate.id === id)
|
||||
if (file?.isDirty) {
|
||||
dirtyFileIds.push(id)
|
||||
const worktreeId = activeWorktreeId
|
||||
// Why: re-read state inside the proceed closure — the aggregated running-process
|
||||
// prompt can sit open arbitrarily long before this actually runs.
|
||||
const performClose = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const dirtyFileIds: string[] = []
|
||||
for (const id of tabIds) {
|
||||
const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find(
|
||||
(candidate) => candidate.id === id || candidate.entityId === id
|
||||
)
|
||||
if (unifiedTab?.isPinned) {
|
||||
continue
|
||||
}
|
||||
closeFile(id)
|
||||
} else if (
|
||||
(state.browserTabsByWorktree[activeWorktreeId] ?? []).some((tab) => tab.id === id)
|
||||
) {
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, id)
|
||||
closeBrowserTab(id)
|
||||
} else if (unifiedTab?.contentType === 'simulator') {
|
||||
// Why: simulator tabs live only in the unified-tab store, so the
|
||||
// entity-store checks above never match them.
|
||||
state.closeUnifiedTab(unifiedTab.id)
|
||||
const runtimeEnvironmentId = getActiveWorktreeRuntimeEnvironmentId(worktreeId)
|
||||
if (
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(unifiedTab?.contentType === 'terminal' ||
|
||||
(unifiedTab?.contentType === 'browser' &&
|
||||
browserWorkspaceHasRemoteOwner(state, unifiedTab.entityId, runtimeEnvironmentId)))
|
||||
) {
|
||||
if (unifiedTab.contentType === 'terminal') {
|
||||
// Why: paired-host bulk close must revoke renderer resume and hook authority, not just remove the host session tab.
|
||||
// The single aggregated prompt already ran above, so skip the per-tab one.
|
||||
closeTerminalTab(unifiedTab.entityId, { skipRunningProcessConfirm: true })
|
||||
} else {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: unifiedTab.id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
if ((state.tabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === id)) {
|
||||
closeTab(id)
|
||||
} else if (
|
||||
state.openFiles.some((file) => file.worktreeId === worktreeId && file.id === id)
|
||||
) {
|
||||
const file = state.openFiles.find((candidate) => candidate.id === id)
|
||||
if (file?.isDirty) {
|
||||
dirtyFileIds.push(id)
|
||||
continue
|
||||
}
|
||||
closeFile(id)
|
||||
} else if ((state.browserTabsByWorktree[worktreeId] ?? []).some((tab) => tab.id === id)) {
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, id)
|
||||
closeBrowserTab(id)
|
||||
} else if (unifiedTab?.contentType === 'simulator') {
|
||||
// Why: simulator tabs live only in the unified-tab store, so the
|
||||
// entity-store checks above never match them.
|
||||
state.closeUnifiedTab(unifiedTab.id)
|
||||
}
|
||||
}
|
||||
if (dirtyFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
}
|
||||
if (dirtyFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyFileIds)
|
||||
}
|
||||
guardBulkTerminalClose({
|
||||
worktreeId,
|
||||
terminalTabIds: collectBulkTerminalTabIds(useAppStore.getState(), worktreeId, tabIds),
|
||||
onProceed: performClose
|
||||
})
|
||||
},
|
||||
[activeWorktreeId, closeBrowserTab, closeFile, closeTab, queueEditorCloseRequests]
|
||||
)
|
||||
|
||||
@@ -59,6 +59,10 @@ import {
|
||||
type FloatingWorkspaceGuestSelectIndexDetail
|
||||
} from '@/lib/floating-workspace-guest-bridge'
|
||||
import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions'
|
||||
import {
|
||||
collectBulkTerminalTabIds,
|
||||
guardBulkTerminalClose
|
||||
} from '@/components/terminal/bulk-terminal-close-guard'
|
||||
import { guardPinnedTabClose, resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
|
||||
import { extractIpcErrorMessage } from '@/lib/ipc-error'
|
||||
import { getShortcutPlatform } from '@/lib/shortcut-platform'
|
||||
@@ -879,39 +883,55 @@ export function FloatingTerminalPanel({
|
||||
|
||||
const closeFloatingItems = useCallback(
|
||||
(visibleIds: string[]) => {
|
||||
const state = useAppStore.getState()
|
||||
const currentGroupTabs = activeGroup
|
||||
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.groupId === activeGroup.id
|
||||
)
|
||||
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
|
||||
const items = visibleIds
|
||||
.map((visibleId) => resolveGroupTabFromVisibleId(currentGroupTabs, visibleId))
|
||||
.filter((item): item is Tab => item !== null && !item.isPinned)
|
||||
if (items.length === 0) {
|
||||
const resolveItems = (): Tab[] => {
|
||||
const latest = useAppStore.getState()
|
||||
const currentGroupTabs = activeGroup
|
||||
? (latest.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
|
||||
(tab) => tab.groupId === activeGroup.id
|
||||
)
|
||||
: (latest.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
|
||||
return visibleIds
|
||||
.map((visibleId) => resolveGroupTabFromVisibleId(currentGroupTabs, visibleId))
|
||||
.filter((item): item is Tab => item !== null && !item.isPinned)
|
||||
}
|
||||
if (resolveItems().length === 0) {
|
||||
return
|
||||
}
|
||||
const dirtyEditorFileIds: string[] = []
|
||||
for (const item of items) {
|
||||
if (item.contentType === 'terminal') {
|
||||
closeTab(item.entityId, { reason: 'cleanup' })
|
||||
} else if (item.contentType === 'browser') {
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const file = state.openFiles.find((candidate) => candidate.id === item.entityId)
|
||||
if (file?.isDirty) {
|
||||
dirtyEditorFileIds.push(item.entityId)
|
||||
continue
|
||||
// Why: re-resolve inside the proceed closure — the aggregated running-process prompt
|
||||
// can sit open arbitrarily long before the close actually runs.
|
||||
const performClose = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const dirtyEditorFileIds: string[] = []
|
||||
for (const item of resolveItems()) {
|
||||
if (item.contentType === 'terminal') {
|
||||
closeTab(item.entityId, { reason: 'cleanup' })
|
||||
} else if (item.contentType === 'browser') {
|
||||
destroyWorkspaceWebviews(state.browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const file = state.openFiles.find((candidate) => candidate.id === item.entityId)
|
||||
if (file?.isDirty) {
|
||||
dirtyEditorFileIds.push(item.entityId)
|
||||
continue
|
||||
}
|
||||
closeFile(item.entityId)
|
||||
}
|
||||
closeFile(item.entityId)
|
||||
}
|
||||
if (dirtyEditorFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyEditorFileIds)
|
||||
}
|
||||
}
|
||||
if (dirtyEditorFileIds.length > 0) {
|
||||
queueEditorCloseRequests(dirtyEditorFileIds)
|
||||
}
|
||||
guardBulkTerminalClose({
|
||||
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
|
||||
terminalTabIds: collectBulkTerminalTabIds(
|
||||
useAppStore.getState(),
|
||||
FLOATING_TERMINAL_WORKTREE_ID,
|
||||
visibleIds
|
||||
),
|
||||
onProceed: performClose
|
||||
})
|
||||
},
|
||||
[activeGroup, closeBrowserTab, closeFile, closeTab, closeUnifiedTab, queueEditorCloseRequests]
|
||||
)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { Tab, TabGroup } from '../../../../shared/tab-types'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
collectBulkTerminalTabIds,
|
||||
guardBulkTerminalClose
|
||||
} from '../terminal/bulk-terminal-close-guard'
|
||||
|
||||
export function useTabGroupCloseScopeCommands({
|
||||
groupId,
|
||||
@@ -15,22 +19,33 @@ export function useTabGroupCloseScopeCommands({
|
||||
worktreeId: string
|
||||
group: TabGroup | null
|
||||
groupTabs: Tab[]
|
||||
closeItem: (itemId: string, opts?: { skipEmptyCheck?: boolean }) => void
|
||||
closeItem: (
|
||||
itemId: string,
|
||||
opts?: { skipEmptyCheck?: boolean; skipRunningProcessConfirm?: boolean }
|
||||
) => void
|
||||
closeMany: (itemIds: string[]) => void
|
||||
leaveWorktreeIfEmpty: () => void
|
||||
}) {
|
||||
const closeEmptyGroup = useAppStore((state) => state.closeEmptyGroup)
|
||||
|
||||
const closeGroup = useCallback(() => {
|
||||
const items = [...(useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])].filter(
|
||||
(item) => item.groupId === groupId
|
||||
)
|
||||
for (const item of items) {
|
||||
closeItem(item.id, { skipEmptyCheck: true })
|
||||
}
|
||||
// Why: closing tabs doesn't remove the group shell; empty split groups are layout state, collapse the placeholder pane here.
|
||||
closeEmptyGroup(worktreeId, groupId)
|
||||
leaveWorktreeIfEmpty()
|
||||
const itemIds = (useAppStore.getState().unifiedTabsByWorktree[worktreeId] ?? [])
|
||||
.filter((item) => item.groupId === groupId)
|
||||
.map((item) => item.id)
|
||||
// Why: one aggregated running-process prompt for the whole pane, matching the
|
||||
// Close Others / to-the-side actions, instead of a modal per busy tab.
|
||||
guardBulkTerminalClose({
|
||||
worktreeId,
|
||||
terminalTabIds: collectBulkTerminalTabIds(useAppStore.getState(), worktreeId, itemIds),
|
||||
onProceed: () => {
|
||||
for (const itemId of itemIds) {
|
||||
closeItem(itemId, { skipEmptyCheck: true, skipRunningProcessConfirm: true })
|
||||
}
|
||||
// Why: closing tabs doesn't remove the group shell; empty split groups are layout state, collapse the placeholder pane here.
|
||||
closeEmptyGroup(worktreeId, groupId)
|
||||
leaveWorktreeIfEmpty()
|
||||
}
|
||||
})
|
||||
}, [closeEmptyGroup, closeItem, groupId, leaveWorktreeIfEmpty, worktreeId])
|
||||
|
||||
const closeAllEditorTabsInGroup = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Tab } from '../../../../shared/tab-types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
closeBrowserTab: vi.fn(),
|
||||
closeFile: vi.fn(),
|
||||
closeTab: vi.fn(),
|
||||
closeTerminalTab: vi.fn(),
|
||||
closeUnifiedTab: vi.fn(),
|
||||
inspectRuntimeTerminalProcess: vi.fn(),
|
||||
setActiveWorktree: vi.fn()
|
||||
}))
|
||||
|
||||
const storeBox = vi.hoisted(() => ({ state: {} as Record<string, unknown> }))
|
||||
|
||||
vi.mock('react', async () => {
|
||||
const actual = await vi.importActual<typeof import('react')>('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import()
|
||||
return { ...actual, useCallback: <T>(callback: T) => callback }
|
||||
})
|
||||
|
||||
vi.mock('../../store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => selector(storeBox.state),
|
||||
{ getState: () => storeBox.state }
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
inspectRuntimeTerminalProcess: mocks.inspectRuntimeTerminalProcess
|
||||
}))
|
||||
|
||||
vi.mock('../../runtime/web-runtime-session', () => ({
|
||||
closeWebRuntimeSessionTab: vi.fn(),
|
||||
isWebRuntimeSessionActive: () => false
|
||||
}))
|
||||
|
||||
vi.mock('../../store/slices/browser-webview-cleanup', () => ({
|
||||
destroyWorkspaceWebviews: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../editor/editor-autosave', () => ({ requestEditorFileClose: vi.fn() }))
|
||||
|
||||
vi.mock('@/lib/worktree-runtime-owner', () => ({
|
||||
getRuntimeEnvironmentIdForWorktree: () => null
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/remote-browser-tab-ownership', () => ({
|
||||
browserWorkspaceHasRemoteOwner: () => false
|
||||
}))
|
||||
|
||||
vi.mock('../terminal/terminal-tab-actions', () => ({ closeTerminalTab: mocks.closeTerminalTab }))
|
||||
|
||||
import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm'
|
||||
import { useTabGroupTabCloseCommands } from './useTabGroupTabCloseCommands'
|
||||
|
||||
const GROUP_TABS = [
|
||||
{ id: 'unified-a', entityId: 'tab-a', contentType: 'terminal', label: 'npm run dev' },
|
||||
{ id: 'unified-b', entityId: 'tab-b', contentType: 'terminal', label: 'pytest' }
|
||||
] as unknown as Tab[]
|
||||
|
||||
// Why: `useCallback` is stubbed to identity above, so the hook is a plain factory here.
|
||||
function useCloseCommands() {
|
||||
return useTabGroupTabCloseCommands({ worktreeId: 'wt-1', groupTabs: GROUP_TABS })
|
||||
}
|
||||
|
||||
async function settleProbe(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('useTabGroupTabCloseCommands closeMany', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
storeBox.state = {
|
||||
settings: {},
|
||||
ptyIdsByTabId: { 'tab-a': ['pty-a'], 'tab-b': ['pty-b'] },
|
||||
terminalLayoutsByTabId: {},
|
||||
unifiedTabsByWorktree: { 'wt-1': GROUP_TABS },
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-a' }, { id: 'tab-b' }] },
|
||||
openFiles: [],
|
||||
browserPagesByWorkspace: {},
|
||||
agentStatusByPaneKey: {},
|
||||
activeWorktreeId: 'wt-1',
|
||||
closeUnifiedTab: mocks.closeUnifiedTab,
|
||||
closeTab: mocks.closeTab,
|
||||
closeFile: mocks.closeFile,
|
||||
closeBrowserTab: mocks.closeBrowserTab,
|
||||
setActiveWorktree: mocks.setActiveWorktree,
|
||||
reconcileWorktreeTabModel: () => ({ renderableTabCount: 1 })
|
||||
}
|
||||
useRunningTerminalCloseConfirmStore.setState({ runningTerminalCloseConfirm: null })
|
||||
})
|
||||
|
||||
it('closes straight through when no tab in the set is busy', async () => {
|
||||
mocks.inspectRuntimeTerminalProcess.mockResolvedValue({
|
||||
hasChildProcesses: false,
|
||||
unavailable: false
|
||||
})
|
||||
|
||||
useCloseCommands().closeMany(['unified-a', 'unified-b'])
|
||||
await settleProbe()
|
||||
|
||||
expect(mocks.closeTab).toHaveBeenCalledTimes(2)
|
||||
expect(useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm).toBeNull()
|
||||
})
|
||||
|
||||
it('holds every close behind one prompt when tabs are busy, then runs them on confirm', async () => {
|
||||
mocks.inspectRuntimeTerminalProcess.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
|
||||
useCloseCommands().closeMany(['unified-a', 'unified-b'])
|
||||
await settleProbe()
|
||||
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
const request = useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm
|
||||
expect(request?.busyTabLabels).toEqual(['npm run dev', 'pytest'])
|
||||
|
||||
useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose()
|
||||
expect(mocks.closeTab).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('closes nothing when the prompt is cancelled', async () => {
|
||||
mocks.inspectRuntimeTerminalProcess.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
|
||||
useCloseCommands().closeMany(['unified-a', 'unified-b'])
|
||||
await settleProbe()
|
||||
useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose()
|
||||
|
||||
expect(mocks.closeTab).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
isWebRuntimeSessionActive
|
||||
} from '../../runtime/web-runtime-session'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import {
|
||||
collectBulkTerminalTabIds,
|
||||
guardBulkTerminalClose
|
||||
} from '../terminal/bulk-terminal-close-guard'
|
||||
import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { browserWorkspaceHasRemoteOwner } from '@/runtime/remote-browser-tab-ownership'
|
||||
|
||||
@@ -62,7 +66,7 @@ export function useTabGroupTabCloseCommands({
|
||||
}, [setActiveWorktree, worktreeId])
|
||||
|
||||
const closeItem = useCallback(
|
||||
(itemId: string, opts?: { skipEmptyCheck?: boolean }) => {
|
||||
(itemId: string, opts?: { skipEmptyCheck?: boolean; skipRunningProcessConfirm?: boolean }) => {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item) {
|
||||
return
|
||||
@@ -77,10 +81,11 @@ export function useTabGroupTabCloseCommands({
|
||||
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 }
|
||||
)
|
||||
closeTerminalTab(item.entityId, {
|
||||
...(opts?.skipEmptyCheck ? {} : { onClosed: leaveWorktreeIfEmpty }),
|
||||
// Why: a whole-group close already asked once for the whole set.
|
||||
...(opts?.skipRunningProcessConfirm ? { skipRunningProcessConfirm: true } : {})
|
||||
})
|
||||
return
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
@@ -127,52 +132,61 @@ export function useTabGroupTabCloseCommands({
|
||||
|
||||
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 === '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') {
|
||||
// Why: see closeItem — host-close a remote-owned browser or pageless host-mirror; always remove the visible tab.
|
||||
const browserState = useAppStore.getState()
|
||||
const hasLocalPages =
|
||||
(browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
|
||||
const shouldCloseOnHost =
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
|
||||
!hasLocalPages)
|
||||
if (shouldCloseOnHost) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: item.id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
// Why: re-resolve inside the proceed closure — the aggregated running-process prompt
|
||||
// can sit open arbitrarily long before the close actually runs.
|
||||
const performClose = (): void => {
|
||||
for (const itemId of itemIds) {
|
||||
const item = groupTabs.find((candidate) => candidate.id === itemId)
|
||||
if (!item || item.isPinned) {
|
||||
continue
|
||||
}
|
||||
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
closeUnifiedTab(item.id)
|
||||
} else if (item.contentType === 'terminal') {
|
||||
closeTab(item.entityId)
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (canCloseTab) {
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForWorktree(
|
||||
useAppStore.getState(),
|
||||
worktreeId
|
||||
)
|
||||
if (item.contentType === 'terminal' && isWebRuntimeSessionActive(runtimeEnvironmentId)) {
|
||||
// Why: revoke local resume + hook authority before the host removes its canonical tab.
|
||||
// The single aggregated prompt already ran, so skip the per-tab one.
|
||||
closeTerminalTab(item.entityId, { skipRunningProcessConfirm: true })
|
||||
continue
|
||||
}
|
||||
if (item.contentType === 'browser') {
|
||||
// Why: see closeItem — host-close a remote-owned browser or pageless host-mirror; always remove the visible tab.
|
||||
const browserState = useAppStore.getState()
|
||||
const hasLocalPages =
|
||||
(browserState.browserPagesByWorkspace[item.entityId] ?? []).length > 0
|
||||
const shouldCloseOnHost =
|
||||
isWebRuntimeSessionActive(runtimeEnvironmentId) &&
|
||||
(browserWorkspaceHasRemoteOwner(browserState, item.entityId, runtimeEnvironmentId) ||
|
||||
!hasLocalPages)
|
||||
if (shouldCloseOnHost) {
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId,
|
||||
tabId: item.id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
}
|
||||
destroyWorkspaceWebviews(browserState.browserPagesByWorkspace, item.entityId)
|
||||
closeBrowserTab(item.entityId)
|
||||
closeUnifiedTab(item.id)
|
||||
} else if (item.contentType === 'terminal') {
|
||||
closeTab(item.entityId)
|
||||
} else if (item.contentType === 'simulator') {
|
||||
closeUnifiedTab(item.id)
|
||||
} else {
|
||||
const canCloseTab = closeEditorIfUnreferenced(item.entityId, item.id)
|
||||
if (canCloseTab) {
|
||||
closeUnifiedTab(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
guardBulkTerminalClose({
|
||||
worktreeId,
|
||||
terminalTabIds: collectBulkTerminalTabIds(useAppStore.getState(), worktreeId, itemIds),
|
||||
onProceed: performClose
|
||||
})
|
||||
},
|
||||
[closeBrowserTab, closeEditorIfUnreferenced, closeTab, closeUnifiedTab, groupTabs, worktreeId]
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ const mountedRoots: Root[] = []
|
||||
|
||||
async function renderDialog(props: {
|
||||
copyKind?: 'command' | 'agent'
|
||||
busyTabLabels?: readonly string[]
|
||||
onConfirm: (dontAskAgain: boolean) => void
|
||||
onCancel?: () => void
|
||||
}): Promise<void> {
|
||||
@@ -22,6 +23,7 @@ async function renderDialog(props: {
|
||||
<CloseTerminalDialog
|
||||
open
|
||||
copyKind={props.copyKind}
|
||||
{...(props.busyTabLabels ? { busyTabLabels: props.busyTabLabels } : {})}
|
||||
onCancel={props.onCancel ?? vi.fn()}
|
||||
onConfirm={props.onConfirm}
|
||||
/>
|
||||
@@ -89,6 +91,49 @@ describe('CloseTerminalDialog', () => {
|
||||
expect(onConfirm).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('counts and names every busy tab for a bulk close', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
|
||||
await renderDialog({
|
||||
copyKind: 'command',
|
||||
busyTabLabels: ['npm run dev', 'pytest', 'tail -f log'],
|
||||
onConfirm
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain('Stop 3 running commands?')
|
||||
expect(document.body.textContent).toContain(
|
||||
'Closing these tabs will stop the commands running inside them.'
|
||||
)
|
||||
for (const label of ['npm run dev', 'pytest', 'tail -f log']) {
|
||||
expect(document.body.textContent).toContain(label)
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
clickButton('Stop and Close')
|
||||
})
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('caps the named tabs and counts the remainder', async () => {
|
||||
await renderDialog({
|
||||
busyTabLabels: ['one', 'two', 'three', 'four', 'five', 'six', 'seven'],
|
||||
onConfirm: vi.fn()
|
||||
})
|
||||
|
||||
expect(document.body.textContent).toContain('Stop 7 running commands?')
|
||||
expect(document.body.textContent).toContain('five')
|
||||
expect(document.body.textContent).not.toContain('six')
|
||||
expect(document.body.textContent).toContain('+2 more')
|
||||
})
|
||||
|
||||
it('keeps the single-tab copy when only one tab in the bulk set is busy', async () => {
|
||||
await renderDialog({ copyKind: 'agent', busyTabLabels: ['claude'], onConfirm: vi.fn() })
|
||||
|
||||
expect(document.body.textContent).toContain('Stop this agent?')
|
||||
expect(document.body.textContent).not.toContain('Stop 1 running')
|
||||
})
|
||||
|
||||
it('resets the skip preference when the dialog closes and reopens', async () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
@@ -14,10 +14,15 @@ import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type CloseTerminalDialogCopyKind = 'command' | 'agent'
|
||||
|
||||
/** Beyond this the list stops naming tabs and counts the rest, so the compact confirmation
|
||||
* never grows into a scrolling wall for a 20-tab "Close Others". */
|
||||
const MAX_LISTED_BUSY_TAB_LABELS = 5
|
||||
|
||||
export default function CloseTerminalDialog({
|
||||
open,
|
||||
copyKind = 'command',
|
||||
tabLabel,
|
||||
busyTabLabels,
|
||||
subjectKey,
|
||||
onCancel,
|
||||
onConfirm
|
||||
@@ -27,6 +32,9 @@ export default function CloseTerminalDialog({
|
||||
/** Names the tab when the prompt can target a tab the user is not looking at
|
||||
* (tab-strip X, middle-click). Omitted for the focused-pane keyboard path. */
|
||||
tabLabel?: string
|
||||
/** Names every busy tab when one prompt covers a bulk close ("Close Others" and the
|
||||
* to-the-side variants). More than one switches the copy to the counted form. */
|
||||
busyTabLabels?: readonly string[]
|
||||
/** Identifies what is being closed, for hosts that reuse one open dialog across a queue
|
||||
* of confirmations. Changing it clears the previous subject's "don't ask again" tick. */
|
||||
subjectKey?: string
|
||||
@@ -58,7 +66,12 @@ export default function CloseTerminalDialog({
|
||||
}
|
||||
|
||||
const isAgent = copyKind === 'agent'
|
||||
const trimmedTabLabel = tabLabel?.trim()
|
||||
const trimmedBusyTabLabels = (busyTabLabels ?? []).map((label) => label.trim()).filter(Boolean)
|
||||
const busyTabCount = trimmedBusyTabLabels.length
|
||||
const isBulk = busyTabCount > 1
|
||||
const listedBusyTabLabels = trimmedBusyTabLabels.slice(0, MAX_LISTED_BUSY_TAB_LABELS)
|
||||
const unlistedBusyTabCount = busyTabCount - listedBusyTabLabels.length
|
||||
const trimmedTabLabel = isBulk ? undefined : tabLabel?.trim()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -72,26 +85,48 @@ export default function CloseTerminalDialog({
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{isAgent
|
||||
? translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title',
|
||||
'Stop this agent?'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title',
|
||||
'Stop running command?'
|
||||
)}
|
||||
{isBulk
|
||||
? isAgent
|
||||
? translate(
|
||||
'components.terminal.pane.CloseTerminalDialog.stopAgentsTitle',
|
||||
'Stop {{count}} running agents?',
|
||||
{ count: busyTabCount }
|
||||
)
|
||||
: translate(
|
||||
'components.terminal.pane.CloseTerminalDialog.stopCommandsTitle',
|
||||
'Stop {{count}} running commands?',
|
||||
{ count: busyTabCount }
|
||||
)
|
||||
: isAgent
|
||||
? translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_title',
|
||||
'Stop this agent?'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_command_title',
|
||||
'Stop running command?'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{isAgent
|
||||
? translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description',
|
||||
"Closing this terminal will stop the agent's current work."
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description',
|
||||
'Closing this terminal will stop the command running inside it.'
|
||||
)}
|
||||
{isBulk
|
||||
? isAgent
|
||||
? translate(
|
||||
'components.terminal.pane.CloseTerminalDialog.stopAgentsDescription',
|
||||
"Closing these tabs will stop the agents' current work."
|
||||
)
|
||||
: translate(
|
||||
'components.terminal.pane.CloseTerminalDialog.stopCommandsDescription',
|
||||
'Closing these tabs will stop the commands running inside them.'
|
||||
)
|
||||
: isAgent
|
||||
? translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_description',
|
||||
"Closing this terminal will stop the agent's current work."
|
||||
)
|
||||
: translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_command_description',
|
||||
'Closing this terminal will stop the command running inside it.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{trimmedTabLabel ? (
|
||||
@@ -99,6 +134,28 @@ export default function CloseTerminalDialog({
|
||||
{trimmedTabLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{isBulk ? (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{listedBusyTabLabels.map((label, index) => (
|
||||
<li
|
||||
key={`${index}-${label}`}
|
||||
className="truncate text-xs font-medium text-foreground"
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</li>
|
||||
))}
|
||||
{unlistedBusyTabCount > 0 ? (
|
||||
<li className="text-xs text-muted-foreground">
|
||||
{translate(
|
||||
'components.terminal.pane.CloseTerminalDialog.moreBusyTabs',
|
||||
'+{{count}} more',
|
||||
{ count: unlistedBusyTabCount }
|
||||
)}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
@@ -123,7 +180,7 @@ export default function CloseTerminalDialog({
|
||||
autoFocus
|
||||
onClick={() => onConfirm(dontAskAgain)}
|
||||
>
|
||||
{isAgent
|
||||
{isAgent && !isBulk
|
||||
? translate(
|
||||
'auto.components.terminal.pane.CloseTerminalDialog.stop_agent_confirm',
|
||||
'Stop Agent'
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function RunningTerminalCloseDialog(): React.JSX.Element {
|
||||
open={request !== null && pinnedRequest === null}
|
||||
copyKind={request?.copyKind ?? 'command'}
|
||||
{...(request?.tabLabel ? { tabLabel: request.tabLabel } : {})}
|
||||
{...(request?.busyTabLabels ? { busyTabLabels: request.busyTabLabels } : {})}
|
||||
// Why: a queued request swaps tabs in the already-open dialog, so the reopen reset
|
||||
// never runs; naming the subject is what clears the previous tab's opt-out tick.
|
||||
{...(request ? { subjectKey: request.terminalTabId } : {})}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getStateMock, inspectRuntimeTerminalProcessMock } = vi.hoisted(() => ({
|
||||
getStateMock: vi.fn(),
|
||||
inspectRuntimeTerminalProcessMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: { getState: getStateMock }
|
||||
}))
|
||||
|
||||
vi.mock('@/runtime/runtime-terminal-inspection', () => ({
|
||||
inspectRuntimeTerminalProcess: inspectRuntimeTerminalProcessMock
|
||||
}))
|
||||
|
||||
import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm'
|
||||
import { RUNNING_CLOSE_PROBE_TIMEOUT_MS } from './running-terminal-close-guard'
|
||||
import { collectBulkTerminalTabIds, guardBulkTerminalClose } from './bulk-terminal-close-guard'
|
||||
|
||||
const LEAF_A = '11111111-1111-4111-8111-111111111111'
|
||||
const LEAF_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function setState(overrides: Record<string, unknown> = {}): void {
|
||||
getStateMock.mockReturnValue({
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
ptyIdsByTabId: { 'tab-a': ['pty-a'], 'tab-b': ['pty-b'] },
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-a': { ptyIdsByLeafId: { [LEAF_A]: 'pty-a' } },
|
||||
'tab-b': { ptyIdsByLeafId: { [LEAF_B]: 'pty-b' } }
|
||||
},
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'unified-a', entityId: 'tab-a', contentType: 'terminal', label: 'npm run dev' },
|
||||
{ id: 'unified-b', entityId: 'tab-b', contentType: 'terminal', label: 'pytest' }
|
||||
]
|
||||
},
|
||||
tabsByWorktree: { 'wt-1': [{ id: 'tab-a' }, { id: 'tab-b' }] },
|
||||
agentStatusByPaneKey: {},
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
function guard(onProceed = vi.fn(), onCancel?: () => void): void {
|
||||
guardBulkTerminalClose({
|
||||
worktreeId: 'wt-1',
|
||||
terminalTabIds: ['tab-a', 'tab-b'],
|
||||
onProceed,
|
||||
...(onCancel ? { onCancel } : {})
|
||||
})
|
||||
}
|
||||
|
||||
function visibleRequest() {
|
||||
return useRunningTerminalCloseConfirmStore.getState().runningTerminalCloseConfirm
|
||||
}
|
||||
|
||||
async function settleProbe(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('collectBulkTerminalTabIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setState()
|
||||
})
|
||||
|
||||
it('accepts unified ids and entity ids, and skips non-terminal and pinned tabs', () => {
|
||||
setState({
|
||||
unifiedTabsByWorktree: {
|
||||
'wt-1': [
|
||||
{ id: 'unified-a', entityId: 'tab-a', contentType: 'terminal' },
|
||||
{ id: 'unified-b', entityId: 'tab-b', contentType: 'terminal', isPinned: true },
|
||||
{ id: 'unified-c', entityId: 'file-c', contentType: 'editor' }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
expect(
|
||||
collectBulkTerminalTabIds(getStateMock(), 'wt-1', ['unified-a', 'tab-b', 'unified-c'])
|
||||
).toEqual(['tab-a'])
|
||||
})
|
||||
|
||||
it('falls back to the runtime tab store for a terminal with no unified row yet', () => {
|
||||
setState({ unifiedTabsByWorktree: { 'wt-1': [] } })
|
||||
|
||||
expect(collectBulkTerminalTabIds(getStateMock(), 'wt-1', ['tab-a', 'unknown'])).toEqual([
|
||||
'tab-a'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('guardBulkTerminalClose', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
setState()
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: false,
|
||||
unavailable: false
|
||||
})
|
||||
useRunningTerminalCloseConfirmStore.setState({ runningTerminalCloseConfirm: null })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('proceeds synchronously when no tab in the set has a live pty', () => {
|
||||
setState({ ptyIdsByTabId: {}, terminalLayoutsByTabId: {} })
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
|
||||
expect(onProceed).toHaveBeenCalledTimes(1)
|
||||
expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled()
|
||||
expect(visibleRequest()).toBeNull()
|
||||
})
|
||||
|
||||
it('proceeds synchronously when the user opted out of the prompt', () => {
|
||||
setState({
|
||||
settings: {
|
||||
activeRuntimeEnvironmentId: null,
|
||||
skipCloseTerminalWithRunningProcessConfirm: true
|
||||
}
|
||||
})
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
|
||||
expect(onProceed).toHaveBeenCalledTimes(1)
|
||||
expect(inspectRuntimeTerminalProcessMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('proceeds without asking when every probe reports an idle shell', async () => {
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
await settleProbe()
|
||||
|
||||
expect(inspectRuntimeTerminalProcessMock).toHaveBeenCalledTimes(2)
|
||||
expect(onProceed).toHaveBeenCalledTimes(1)
|
||||
expect(visibleRequest()).toBeNull()
|
||||
})
|
||||
|
||||
it('raises one aggregated prompt naming every busy tab', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
await settleProbe()
|
||||
|
||||
expect(onProceed).not.toHaveBeenCalled()
|
||||
const request = visibleRequest()
|
||||
expect(request?.busyTabLabels).toEqual(['npm run dev', 'pytest'])
|
||||
expect(request?.terminalTabId).toBe('bulk:tab-a,tab-b')
|
||||
expect(request?.copyKind).toBe('command')
|
||||
|
||||
useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose()
|
||||
expect(onProceed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lists only the busy tabs, keeping strip order', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockImplementation((_settings, ptyId: string) =>
|
||||
Promise.resolve({ hasChildProcesses: ptyId === 'pty-b', unavailable: false })
|
||||
)
|
||||
|
||||
guard()
|
||||
await settleProbe()
|
||||
|
||||
const request = visibleRequest()
|
||||
expect(request?.busyTabLabels).toEqual(['pytest'])
|
||||
// Why: a lone busy tab keys on its real id so it merges with that tab's own prompt.
|
||||
expect(request?.terminalTabId).toBe('tab-b')
|
||||
})
|
||||
|
||||
it('cancelling abandons the whole bulk close', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
const onProceed = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
guard(onProceed, onCancel)
|
||||
await settleProbe()
|
||||
useRunningTerminalCloseConfirmStore.getState().dismissRunningTerminalClose()
|
||||
|
||||
expect(onProceed).not.toHaveBeenCalled()
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('words the prompt for agents when any busy pane is one', async () => {
|
||||
setState({
|
||||
agentStatusByPaneKey: { [`tab-b:${LEAF_B}`]: { agentType: 'claude' } }
|
||||
})
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
|
||||
guard()
|
||||
await settleProbe()
|
||||
|
||||
expect(visibleRequest()?.copyKind).toBe('agent')
|
||||
})
|
||||
|
||||
it('proceeds on an answered probe that could not inspect the shell', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: true
|
||||
})
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
await settleProbe()
|
||||
|
||||
expect(onProceed).toHaveBeenCalledTimes(1)
|
||||
expect(visibleRequest()).toBeNull()
|
||||
})
|
||||
|
||||
it('asks rather than closes when the probe never answers', async () => {
|
||||
vi.useFakeTimers()
|
||||
inspectRuntimeTerminalProcessMock.mockReturnValue(new Promise(() => {}))
|
||||
const onProceed = vi.fn()
|
||||
|
||||
guard(onProceed)
|
||||
vi.advanceTimersByTime(RUNNING_CLOSE_PROBE_TIMEOUT_MS)
|
||||
|
||||
expect(onProceed).not.toHaveBeenCalled()
|
||||
expect(visibleRequest()?.busyTabLabels).toEqual(['npm run dev', 'pytest'])
|
||||
})
|
||||
|
||||
it('merges a repeated bulk close into the open prompt instead of stacking one', async () => {
|
||||
inspectRuntimeTerminalProcessMock.mockResolvedValue({
|
||||
hasChildProcesses: true,
|
||||
unavailable: false
|
||||
})
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
|
||||
guard(first)
|
||||
await settleProbe()
|
||||
guard(second)
|
||||
await settleProbe()
|
||||
|
||||
useRunningTerminalCloseConfirmStore.getState().confirmRunningTerminalClose()
|
||||
expect(first).toHaveBeenCalledTimes(1)
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
expect(visibleRequest()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useAppStore } from '@/store'
|
||||
import { inspectRuntimeTerminalProcess } from '@/runtime/runtime-terminal-inspection'
|
||||
import { resolvePinnedTabLabel } from '@/store/pinned-tab-close-guard'
|
||||
import { useRunningTerminalCloseConfirmStore } from '@/store/running-terminal-close-confirm'
|
||||
import type { AppState } from '@/store/types'
|
||||
import type { CloseTerminalDialogCopyKind } from '../terminal-pane/CloseTerminalDialog'
|
||||
import { collectTabPtyIds, RUNNING_CLOSE_PROBE_TIMEOUT_MS } from './running-terminal-close-guard'
|
||||
import { resolveBusyPtyCloseCopyKind } from './terminal-close-copy-kind'
|
||||
|
||||
/** A terminal tab in the bulk set, with the PTYs it could still own. */
|
||||
type BulkCloseCandidate = { terminalTabId: string; ptyIds: string[] }
|
||||
|
||||
/**
|
||||
* Terminal tab ids (entity ids) inside a mixed tab-strip id list, skipping the pinned tabs
|
||||
* a bulk close leaves alone. Accepts either unified-tab ids or entity ids because the tab
|
||||
* strip emits entity ids for terminals and unified ids for editors.
|
||||
*/
|
||||
export function collectBulkTerminalTabIds(
|
||||
state: Pick<AppState, 'unifiedTabsByWorktree' | 'tabsByWorktree'>,
|
||||
worktreeId: string,
|
||||
visibleIds: readonly string[]
|
||||
): string[] {
|
||||
const unifiedTabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
|
||||
const terminalTabIds: string[] = []
|
||||
for (const visibleId of visibleIds) {
|
||||
const unifiedTab = unifiedTabs.find(
|
||||
(candidate) => candidate.id === visibleId || candidate.entityId === visibleId
|
||||
)
|
||||
if (unifiedTab) {
|
||||
if (unifiedTab.contentType === 'terminal' && unifiedTab.isPinned !== true) {
|
||||
terminalTabIds.push(unifiedTab.entityId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Why: agent quick-launch can briefly leave a terminal in the runtime store before its
|
||||
// unified row lands; missing it here would silently drop the prompt for a busy tab.
|
||||
if ((state.tabsByWorktree?.[worktreeId] ?? []).some((tab) => tab.id === visibleId)) {
|
||||
terminalTabIds.push(visibleId)
|
||||
}
|
||||
}
|
||||
return terminalTabIds
|
||||
}
|
||||
|
||||
/** Stable dedupe key so a double-fired bulk close merges into one prompt. A lone busy tab
|
||||
* keys on its real id, so it also merges with that tab's own single-close prompt. */
|
||||
function resolveRequestKey(busyTabIds: readonly string[]): string {
|
||||
return busyTabIds.length === 1 ? busyTabIds[0]! : `bulk:${[...busyTabIds].sort().join(',')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes "Close Others" / "Close Tabs To The Right" / "Close Tabs To The Left" through one
|
||||
* aggregated running-process confirmation instead of the modal storm that made these paths
|
||||
* opt out of the prompt entirely. Proceeds synchronously when no tab in the set has a live
|
||||
* PTY, so idle bulk closes keep today's behavior.
|
||||
*
|
||||
* Cancel abandons the whole bulk close rather than closing the idle subset: a partial,
|
||||
* un-undoable close is a worse surprise than doing nothing and letting the user retry.
|
||||
*/
|
||||
export function guardBulkTerminalClose(params: {
|
||||
worktreeId: string
|
||||
terminalTabIds: readonly string[]
|
||||
onProceed: () => void
|
||||
onCancel?: () => void
|
||||
}): void {
|
||||
const { worktreeId, terminalTabIds, onProceed, onCancel } = params
|
||||
const state = useAppStore.getState()
|
||||
const settings = state.settings
|
||||
const candidates: BulkCloseCandidate[] = []
|
||||
for (const terminalTabId of new Set(terminalTabIds)) {
|
||||
const ptyIds = collectTabPtyIds(state, terminalTabId)
|
||||
if (ptyIds.length > 0) {
|
||||
candidates.push({ terminalTabId, ptyIds })
|
||||
}
|
||||
}
|
||||
// Why: nothing to probe (parked/hibernated tabs, or a set with no terminals at all) and
|
||||
// the opt-out setting both mean the answer is known, so the close stays synchronous.
|
||||
if (candidates.length === 0 || settings?.skipCloseTerminalWithRunningProcessConfirm === true) {
|
||||
onProceed()
|
||||
return
|
||||
}
|
||||
|
||||
// Why: the timeout, the probe result and the error path race to decide this close, so the
|
||||
// first one to land owns it instead of trusting those races to stay mutually exclusive.
|
||||
let decided = false
|
||||
const proceedNow = (): void => {
|
||||
if (decided) {
|
||||
return
|
||||
}
|
||||
decided = true
|
||||
onProceed()
|
||||
}
|
||||
const confirmClose = (busy: readonly BulkCloseCandidate[]): void => {
|
||||
if (decided) {
|
||||
return
|
||||
}
|
||||
const latest = useAppStore.getState()
|
||||
const busyTabLabels = busy.map((candidate) =>
|
||||
resolvePinnedTabLabel(latest, worktreeId, candidate.terminalTabId)
|
||||
)
|
||||
// Why: an agent anywhere in the set wins the wording, matching the single-tab prompt's
|
||||
// mixed-split rule — stopping an agent mid-task is the costlier surprise.
|
||||
const copyKind: CloseTerminalDialogCopyKind = busy.some(
|
||||
(candidate) =>
|
||||
resolveBusyPtyCloseCopyKind(candidate.terminalTabId, candidate.ptyIds) === 'agent'
|
||||
)
|
||||
? 'agent'
|
||||
: 'command'
|
||||
useRunningTerminalCloseConfirmStore.getState().requestRunningTerminalCloseConfirm({
|
||||
terminalTabId: resolveRequestKey(busy.map((candidate) => candidate.terminalTabId)),
|
||||
tabLabel: busyTabLabels[0] ?? '',
|
||||
busyTabLabels,
|
||||
copyKind,
|
||||
onConfirm: onProceed,
|
||||
...(onCancel ? { onCancel } : {})
|
||||
})
|
||||
// Why: only once the prompt is actually up — if either call above throws, the close must
|
||||
// still be free to fall through and happen.
|
||||
decided = true
|
||||
}
|
||||
|
||||
const probes = candidates.flatMap((candidate) =>
|
||||
candidate.ptyIds.map((ptyId) => ({ candidate, ptyId }))
|
||||
)
|
||||
const probeTimeout = setTimeout(() => {
|
||||
try {
|
||||
// Why: a probe that has not answered yet is unknown, not idle. Ask about every
|
||||
// candidate so a degraded relay costs a click instead of killed remote work.
|
||||
confirmClose(candidates)
|
||||
} catch {
|
||||
proceedNow()
|
||||
}
|
||||
}, RUNNING_CLOSE_PROBE_TIMEOUT_MS)
|
||||
|
||||
void Promise.allSettled(probes.map(({ ptyId }) => inspectRuntimeTerminalProcess(settings, ptyId)))
|
||||
.then((results) => {
|
||||
clearTimeout(probeTimeout)
|
||||
if (decided) {
|
||||
return
|
||||
}
|
||||
// Why: fail open on an *answered* probe, matching the single-tab guard — a rejection
|
||||
// or a stale remote handle is not evidence of a live child, and a menu action that
|
||||
// silently does nothing is worse than closing a busy tab.
|
||||
const busyPtyIdsByTabId = new Map<string, string[]>()
|
||||
results.forEach((result, index) => {
|
||||
if (
|
||||
result.status !== 'fulfilled' ||
|
||||
!result.value.hasChildProcesses ||
|
||||
result.value.unavailable === true
|
||||
) {
|
||||
return
|
||||
}
|
||||
const probe = probes[index]!
|
||||
const busyPtyIds = busyPtyIdsByTabId.get(probe.candidate.terminalTabId)
|
||||
if (busyPtyIds) {
|
||||
busyPtyIds.push(probe.ptyId)
|
||||
} else {
|
||||
busyPtyIdsByTabId.set(probe.candidate.terminalTabId, [probe.ptyId])
|
||||
}
|
||||
})
|
||||
if (busyPtyIdsByTabId.size === 0) {
|
||||
proceedNow()
|
||||
return
|
||||
}
|
||||
// Why: walk `candidates` rather than the map so the prompt lists tabs in strip order.
|
||||
confirmClose(
|
||||
candidates
|
||||
.filter((candidate) => busyPtyIdsByTabId.has(candidate.terminalTabId))
|
||||
.map((candidate) => ({
|
||||
terminalTabId: candidate.terminalTabId,
|
||||
ptyIds: busyPtyIdsByTabId.get(candidate.terminalTabId)!
|
||||
}))
|
||||
)
|
||||
})
|
||||
// Why: allSettled never rejects, so this only fires when the decision above throws (a
|
||||
// label lookup, a store subscriber). Without it the bulk close would silently never
|
||||
// happen and the user would get no feedback at all.
|
||||
.catch(() => {
|
||||
clearTimeout(probeTimeout)
|
||||
proceedNow()
|
||||
})
|
||||
}
|
||||
@@ -22,8 +22,9 @@ export type RunningTerminalCloseGuardOptions = {
|
||||
export const RUNNING_CLOSE_PROBE_TIMEOUT_MS = 4_000
|
||||
|
||||
/** Whether this close is an interactive user action that should stop and ask before
|
||||
* killing a live child process. Lifecycle echoes, bulk closes, CLI/RPC closes and the
|
||||
* post-confirmation re-entry are all excluded. */
|
||||
* killing a live child process. Lifecycle echoes, CLI/RPC closes and the
|
||||
* post-confirmation re-entry are all excluded, as are bulk closes — those raise one
|
||||
* aggregated prompt up front (see bulk-terminal-close-guard) and opt each tab out here. */
|
||||
export function shouldConfirmRunningTerminalClose(
|
||||
options?: RunningTerminalCloseGuardOptions
|
||||
): boolean {
|
||||
@@ -43,7 +44,7 @@ export function shouldConfirmRunningTerminalClose(
|
||||
* the store's own teardown collector unions both for exactly that reason — reading only
|
||||
* the map would let a close slip through the window with no prompt. A stale id costs
|
||||
* nothing: its probe fails and the guard falls open. */
|
||||
function collectTabPtyIds(
|
||||
export function collectTabPtyIds(
|
||||
state: Pick<AppState, 'ptyIdsByTabId' | 'terminalLayoutsByTabId'>,
|
||||
terminalTabId: string
|
||||
): string[] {
|
||||
|
||||
@@ -46,8 +46,9 @@ export function closeTerminalTab(
|
||||
hostCloseReason?: TerminalTabCloseReason
|
||||
/** PTY whose lifecycle event initiated the host close. */
|
||||
lifecyclePtyId?: string
|
||||
/** Set by callers that must never raise a modal (bulk closes, CLI/RPC, lifecycle
|
||||
* fallbacks) and by the re-entry that runs once the user confirmed. */
|
||||
/** Set by callers that must never raise a modal here (CLI/RPC, lifecycle fallbacks,
|
||||
* and bulk closes that already asked once for the whole set) and by the re-entry
|
||||
* that runs once the user confirmed. */
|
||||
skipRunningProcessConfirm?: boolean
|
||||
captureRecentlyClosed?: boolean
|
||||
localPtyTeardownOwnedExternally?: boolean
|
||||
|
||||
@@ -7,6 +7,7 @@ import { closeWebRuntimeSessionTab, isWebRuntimeSessionActive } from '@/runtime/
|
||||
import { useAppStore } from '@/store'
|
||||
import { reconcileTabOrder } from '../tab-bar/reconcile-order'
|
||||
import { closeLocalTerminalTabState } from './close-local-terminal-tab-state'
|
||||
import { collectBulkTerminalTabIds, guardBulkTerminalClose } from './bulk-terminal-close-guard'
|
||||
|
||||
const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
'editor',
|
||||
@@ -38,29 +39,39 @@ export function closeOtherTerminalTabs(tabId: string, activeWorktreeId: string |
|
||||
return
|
||||
}
|
||||
const currentTabs = state.tabsByWorktree[activeWorktreeId] ?? []
|
||||
state.setActiveTab(tabId)
|
||||
const runtimeEnvironmentId = resolveTerminalWorktreeRoute(
|
||||
state,
|
||||
activeWorktreeId
|
||||
)?.runtimeEnvironmentId
|
||||
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
for (const tab of currentTabs) {
|
||||
if (tab.id === tabId || isPinnedVisibleTab(state, activeWorktreeId, tab.id)) {
|
||||
continue
|
||||
}
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: prune the mirror immediately, then close on its authoritative host so snapshots converge.
|
||||
closeLocalTerminalTabState(tab.id, { remoteCloseOwnedByHost: true })
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: tab.id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
state.closeTab(tab.id)
|
||||
const closableIds = currentTabs
|
||||
.map((tab) => tab.id)
|
||||
.filter((id) => id !== tabId && !isPinnedVisibleTab(state, activeWorktreeId, id))
|
||||
// Why: activate and close only once the aggregated running-process prompt is answered, so
|
||||
// cancelling leaves both the tab strip and the running work untouched.
|
||||
const performClose = (): void => {
|
||||
const latest = useAppStore.getState()
|
||||
latest.setActiveTab(tabId)
|
||||
const runtimeEnvironmentId = resolveTerminalWorktreeRoute(
|
||||
latest,
|
||||
activeWorktreeId
|
||||
)?.runtimeEnvironmentId
|
||||
const closeHostTerminalTabs = isWebRuntimeSessionActive(runtimeEnvironmentId)
|
||||
for (const id of closableIds) {
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: prune the mirror immediately, then close on its authoritative host so snapshots converge.
|
||||
closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true })
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
latest.closeTab(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
guardBulkTerminalClose({
|
||||
worktreeId: activeWorktreeId,
|
||||
terminalTabIds: collectBulkTerminalTabIds(state, activeWorktreeId, closableIds),
|
||||
onProceed: performClose
|
||||
})
|
||||
}
|
||||
|
||||
export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string | null): void {
|
||||
@@ -91,30 +102,37 @@ export function closeTerminalTabsToRight(tabId: string, activeWorktreeId: string
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
for (const id of orderedIds.slice(index + 1)) {
|
||||
if (isPinnedVisibleTab(state, activeWorktreeId, id)) {
|
||||
continue
|
||||
}
|
||||
if (terminalIdSet.has(id)) {
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: prune the mirror immediately, then close on its authoritative host so snapshots converge.
|
||||
closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true })
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
state.closeTab(id)
|
||||
const closableIds = orderedIds
|
||||
.slice(index + 1)
|
||||
.filter((id) => !isPinnedVisibleTab(state, activeWorktreeId, id))
|
||||
const performClose = (): void => {
|
||||
for (const id of closableIds) {
|
||||
if (terminalIdSet.has(id)) {
|
||||
if (closeHostTerminalTabs) {
|
||||
// Why: prune the mirror immediately, then close on its authoritative host so snapshots converge.
|
||||
closeLocalTerminalTabState(id, { remoteCloseOwnedByHost: true })
|
||||
void closeWebRuntimeSessionTab({
|
||||
worktreeId: activeWorktreeId,
|
||||
tabId: id,
|
||||
environmentId: runtimeEnvironmentId,
|
||||
reason: 'user'
|
||||
})
|
||||
} else {
|
||||
useAppStore.getState().closeTab(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const unifiedTab = (
|
||||
useAppStore.getState().unifiedTabsByWorktree?.[activeWorktreeId] ?? []
|
||||
).find((tab) => tab.entityId === id && EDITOR_TAB_CONTENT_TYPES.has(tab.contentType))
|
||||
if (!unifiedTab?.isPinned) {
|
||||
useAppStore.getState().closeFile(id)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const unifiedTab = (state.unifiedTabsByWorktree?.[activeWorktreeId] ?? []).find(
|
||||
(tab) => tab.entityId === id && EDITOR_TAB_CONTENT_TYPES.has(tab.contentType)
|
||||
)
|
||||
if (!unifiedTab?.isPinned) {
|
||||
useAppStore.getState().closeFile(id)
|
||||
}
|
||||
}
|
||||
guardBulkTerminalClose({
|
||||
worktreeId: activeWorktreeId,
|
||||
terminalTabIds: closableIds.filter((id) => terminalIdSet.has(id)),
|
||||
onProceed: performClose
|
||||
})
|
||||
}
|
||||
|
||||
@@ -16295,6 +16295,17 @@
|
||||
"days": "{{value0}}d",
|
||||
"underOneMinute": "<1m"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"pane": {
|
||||
"CloseTerminalDialog": {
|
||||
"stopAgentsTitle": "Stop {{count}} running agents?",
|
||||
"stopCommandsTitle": "Stop {{count}} running commands?",
|
||||
"stopAgentsDescription": "Closing these tabs will stop the agents' current work.",
|
||||
"stopCommandsDescription": "Closing these tabs will stop the commands running inside them.",
|
||||
"moreBusyTabs": "+{{count}} more"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dashboardPopout": {
|
||||
|
||||
@@ -4,8 +4,12 @@ import type { CloseTerminalDialogCopyKind } from '@/components/terminal-pane/Clo
|
||||
/** A pending confirmation for closing a terminal tab whose shell still has a
|
||||
* running child process. `onConfirm` performs the original close. */
|
||||
export type RunningTerminalCloseConfirmRequest = {
|
||||
/** Also the queue dedupe key, so a bulk close passes a synthetic id covering its set. */
|
||||
terminalTabId: string
|
||||
tabLabel: string
|
||||
/** Present when one prompt covers a bulk close; names every tab that reported a live
|
||||
* child so the counted copy can list them. */
|
||||
busyTabLabels?: readonly string[]
|
||||
copyKind: CloseTerminalDialogCopyKind
|
||||
onConfirm: () => void
|
||||
onCancel?: () => void
|
||||
|
||||
Reference in New Issue
Block a user