Prevent bulk tab closing from silently abandoning running sessions

Bulk tab actions reused a direct disconnect path instead of the single-tab running-session confirmation path. The tab bar now collects running sessions before closing any selected tab set, prompts when active work would be affected, and applies the user's stop-or-keep choice consistently across close all, close others, close left, and close right.

Constraint: Desktop users can run multiple sessions concurrently and must not lose running work through a bulk tab action without confirmation
Rejected: Patch only Close All | the neighboring bulk actions had the same direct disconnect bypass
Confidence: high
Scope-risk: narrow
Directive: Keep bulk tab closing routed through the same running-session policy as single tab closing
Tested: cd desktop && bun run test src/components/layout/TabBar.test.tsx --pool forks --poolOptions.forks.singleFork=true
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: git diff --check
Not-tested: Manual desktop UI click-through
This commit is contained in:
程序员阿江(Relakkes) 2026-05-19 00:20:41 +08:00
parent 82609f6db6
commit 5f97f9ac62
4 changed files with 164 additions and 48 deletions

View File

@ -1,6 +1,8 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import type { PerSessionState } from '../../stores/chatStore'
import type { ChatState } from '../../types/chat'
const startDraggingMock = vi.hoisted(() => vi.fn(() => Promise.resolve()))
const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
@ -14,12 +16,37 @@ const openProjectMenuMock = vi.hoisted(() => ({
paths: [] as Array<string | null | undefined>,
}))
function makeChatSession(chatState: ChatState): PerSessionState {
return {
messages: [],
chatState,
connectionState: 'connected',
streamingText: '',
streamingToolInput: '',
activeToolUseId: null,
activeToolName: null,
activeThinkingId: null,
pendingPermission: null,
pendingComputerUsePermission: null,
tokenUsage: { input_tokens: 0, output_tokens: 0 },
elapsedSeconds: 0,
statusVerb: '',
slashCommands: [],
agentTaskNotifications: {},
backgroundAgentTasks: {},
activeGoal: null,
elapsedTimer: null,
composerPrefill: null,
composerDraft: null,
}
}
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: getCurrentWindowMock,
}))
vi.mock('../../i18n', () => ({
useTranslation: () => (key: string) => {
useTranslation: () => (key: string, params?: Record<string, string | number>) => {
const translations: Record<string, string> = {
'tabs.close': 'Close',
'tabs.closeOthers': 'Close Others',
@ -30,6 +57,9 @@ vi.mock('../../i18n', () => ({
'tabs.closeConfirmMessage': 'Still running',
'tabs.closeConfirmKeep': 'Keep Running',
'tabs.closeConfirmStop': 'Stop & Close',
'tabs.closeAllConfirmTitle': 'Sessions Running',
'tabs.closeAllConfirmMessage': '{count} sessions still running',
'tabs.closeAllConfirmStop': 'Stop All & Close',
'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace',
@ -39,7 +69,13 @@ vi.mock('../../i18n', () => ({
'common.cancel': 'Cancel',
}
return translations[key] ?? key
let text = translations[key] ?? key
if (params) {
for (const [paramKey, paramValue] of Object.entries(params)) {
text = text.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramValue))
}
}
return text
},
}))
@ -748,4 +784,54 @@ describe('TabBar', () => {
expect(useWorkspacePanelStore.getState().panelBySession['tab-1']).toBeUndefined()
expect(useTerminalPanelStore.getState().panelBySession['tab-1']).toBeUndefined()
})
it('asks before stopping running sessions when closing all tabs', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const disconnectSession = vi.fn()
const stopGeneration = vi.fn()
useTabStore.setState({
tabs: [
{ sessionId: 'tab-running', title: 'Running Session', type: 'session', status: 'running' },
{ sessionId: 'tab-thinking', title: 'Thinking Session', type: 'session', status: 'running' },
{ sessionId: 'tab-idle', title: 'Idle Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-running',
})
useChatStore.setState({
sessions: {
'tab-running': makeChatSession('streaming'),
'tab-thinking': makeChatSession('thinking'),
'tab-idle': makeChatSession('idle'),
},
disconnectSession,
stopGeneration,
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.contextMenu(screen.getByText('Running Session'))
fireEvent.click(screen.getByText('Close All'))
expect(screen.getByText('Sessions Running')).toBeInTheDocument()
expect(screen.getByText('2 sessions still running')).toBeInTheDocument()
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-running', 'tab-thinking', 'tab-idle'])
expect(disconnectSession).not.toHaveBeenCalled()
expect(stopGeneration).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Stop All & Close'))
expect(stopGeneration).toHaveBeenCalledWith('tab-running')
expect(stopGeneration).toHaveBeenCalledWith('tab-thinking')
expect(stopGeneration).toHaveBeenCalledTimes(2)
expect(disconnectSession).toHaveBeenCalledWith('tab-running')
expect(disconnectSession).toHaveBeenCalledWith('tab-thinking')
expect(disconnectSession).toHaveBeenCalledWith('tab-idle')
expect(useTabStore.getState().tabs).toEqual([])
})
})

View File

@ -19,6 +19,11 @@ const TAB_WIDTH = 180
const DRAG_START_THRESHOLD = 4
const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)
type PendingCloseRequest = {
tabs: Tab[]
runningSessionIds: string[]
}
function isSessionTab(tab: Tab | null) {
if (!tab) return false
const tabType = (tab as Partial<Tab>).type
@ -60,7 +65,7 @@ export function TabBar() {
const [canScrollLeft, setCanScrollLeft] = useState(false)
const [canScrollRight, setCanScrollRight] = useState(false)
const [contextMenu, setContextMenu] = useState<{ sessionId: string; x: number; y: number } | null>(null)
const [closingTabId, setClosingTabId] = useState<string | null>(null)
const [pendingCloseRequest, setPendingCloseRequest] = useState<PendingCloseRequest | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null)
const [dragOffsetX, setDragOffsetX] = useState(0)
@ -137,25 +142,50 @@ export function TabBar() {
closeTab(tab.sessionId)
}, [closeTab])
const getRunningSessionIds = useCallback((targetTabs: Tab[]) => {
const chatSessions = useChatStore.getState().sessions
return targetTabs
.filter((tab) => isSessionTab(tab))
.filter((tab) => {
const sessionState = chatSessions[tab.sessionId]
return !!sessionState && sessionState.chatState !== 'idle'
})
.map((tab) => tab.sessionId)
}, [])
const closeTabsWithPolicy = useCallback((targetTabs: Tab[], runningSessionIds: string[], stopRunning: boolean) => {
const runningSessionSet = new Set(runningSessionIds)
for (const tab of targetTabs) {
if (isSessionTab(tab)) {
const isRunning = runningSessionSet.has(tab.sessionId)
if (isRunning && stopRunning) {
useChatStore.getState().stopGeneration(tab.sessionId)
}
if (!isRunning || stopRunning) {
disconnectSession(tab.sessionId)
}
}
closeTabWithCleanup(tab)
}
}, [closeTabWithCleanup, disconnectSession])
const requestCloseTabs = useCallback((targetTabs: Tab[]) => {
if (targetTabs.length === 0) return
const runningSessionIds = getRunningSessionIds(targetTabs)
if (runningSessionIds.length > 0) {
setPendingCloseRequest({ tabs: targetTabs, runningSessionIds })
return
}
closeTabsWithPolicy(targetTabs, [], false)
}, [closeTabsWithPolicy, getRunningSessionIds])
const handleClose = (sessionId: string) => {
// Special tabs can always be closed directly
const tab = tabs.find((t) => t.sessionId === sessionId)
if (!tab) return
if (!isSessionTab(tab)) {
closeTabWithCleanup(tab)
return
}
const sessionState = useChatStore.getState().sessions[sessionId]
const isRunning = sessionState && sessionState.chatState !== 'idle'
if (isRunning) {
setClosingTabId(sessionId)
return
}
disconnectSession(sessionId)
closeTabWithCleanup(tab)
requestCloseTabs([tab])
}
const handleContextMenu = (e: React.MouseEvent, sessionId: string) => {
@ -166,38 +196,26 @@ export function TabBar() {
const handleCloseOthers = (sessionId: string) => {
setContextMenu(null)
const otherTabs = tabs.filter((t) => t.sessionId !== sessionId)
for (const tab of otherTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
closeTabWithCleanup(tab)
}
requestCloseTabs(otherTabs)
}
const handleCloseLeft = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const leftTabs = tabs.slice(0, idx)
for (const tab of leftTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
closeTabWithCleanup(tab)
}
requestCloseTabs(leftTabs)
}
const handleCloseRight = (sessionId: string) => {
setContextMenu(null)
const idx = tabs.findIndex((t) => t.sessionId === sessionId)
const rightTabs = tabs.slice(idx + 1)
for (const tab of rightTabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
closeTabWithCleanup(tab)
}
requestCloseTabs(rightTabs)
}
const handleCloseAll = () => {
setContextMenu(null)
for (const tab of tabs) {
if (isSessionTab(tab)) disconnectSession(tab.sessionId)
closeTabWithCleanup(tab)
}
requestCloseTabs(tabs)
}
const getTargetIndexFromClientX = useCallback((clientX: number) => {
@ -408,20 +426,27 @@ export function TabBar() {
</div>
)}
{closingTabId && (
{pendingCloseRequest && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/30">
<div className="bg-[var(--color-surface)] rounded-xl border border-[var(--color-border)] p-6 max-w-sm w-full mx-4" style={{ boxShadow: 'var(--shadow-dropdown)' }}>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">{t('tabs.closeConfirmTitle')}</h3>
<p className="text-xs text-[var(--color-text-secondary)] mb-4">{t('tabs.closeConfirmMessage')}</p>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)] mb-2">
{pendingCloseRequest.runningSessionIds.length > 1
? t('tabs.closeAllConfirmTitle')
: t('tabs.closeConfirmTitle')}
</h3>
<p className="text-xs text-[var(--color-text-secondary)] mb-4">
{pendingCloseRequest.runningSessionIds.length > 1
? t('tabs.closeAllConfirmMessage', { count: pendingCloseRequest.runningSessionIds.length })
: t('tabs.closeConfirmMessage')}
</p>
<div className="flex justify-end gap-2">
<button onClick={() => setClosingTabId(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
<button onClick={() => setPendingCloseRequest(null)} className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]">
{t('common.cancel')}
</button>
<button
onClick={() => {
const tab = tabs.find((item) => item.sessionId === closingTabId)
if (tab) closeTabWithCleanup(tab)
setClosingTabId(null)
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, false)
setPendingCloseRequest(null)
}}
className="px-3 py-1.5 text-xs rounded-lg border border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]"
>
@ -429,15 +454,14 @@ export function TabBar() {
</button>
<button
onClick={() => {
useChatStore.getState().stopGeneration(closingTabId)
disconnectSession(closingTabId)
const tab = tabs.find((item) => item.sessionId === closingTabId)
if (tab) closeTabWithCleanup(tab)
setClosingTabId(null)
closeTabsWithPolicy(pendingCloseRequest.tabs, pendingCloseRequest.runningSessionIds, true)
setPendingCloseRequest(null)
}}
className="px-3 py-1.5 text-xs rounded-lg bg-[var(--color-brand)] text-white hover:opacity-90"
>
{t('tabs.closeConfirmStop')}
{pendingCloseRequest.runningSessionIds.length > 1
? t('tabs.closeAllConfirmStop')
: t('tabs.closeConfirmStop')}
</button>
</div>
</div>

View File

@ -1497,6 +1497,9 @@ export const en = {
'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?',
'tabs.closeConfirmKeep': 'Keep Running',
'tabs.closeConfirmStop': 'Stop & Close',
'tabs.closeAllConfirmTitle': 'Sessions Running',
'tabs.closeAllConfirmMessage': '{count} sessions are still running. Stop them before closing these tabs?',
'tabs.closeAllConfirmStop': 'Stop All & Close',
'tabs.openTerminal': 'Open Terminal',
'tabs.showWorkspace': 'Show Workspace',
'tabs.hideWorkspace': 'Hide Workspace',

View File

@ -1499,6 +1499,9 @@ export const zh: Record<TranslationKey, string> = {
'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?',
'tabs.closeConfirmKeep': '保持运行',
'tabs.closeConfirmStop': '停止并关闭',
'tabs.closeAllConfirmTitle': '多个会话运行中',
'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?',
'tabs.closeAllConfirmStop': '全部停止并关闭',
'tabs.openTerminal': '打开终端',
'tabs.showWorkspace': '显示工作区',
'tabs.hideWorkspace': '隐藏工作区',