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:
Neil
2026-08-25 00:33:29 -07:00
parent 3d74f5fe03
commit 876838551d
15 changed files with 976 additions and 204 deletions
+63 -51
View File
@@ -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
})
}
+11
View File
@@ -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