From 5f97f9ac6217039fe3d8561fdd4f69c976cd599b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 19 May 2026 00:20:41 +0800 Subject: [PATCH] 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 --- desktop/src/components/layout/TabBar.test.tsx | 90 +++++++++++++- desktop/src/components/layout/TabBar.tsx | 116 +++++++++++------- desktop/src/i18n/locales/en.ts | 3 + desktop/src/i18n/locales/zh.ts | 3 + 4 files changed, 164 insertions(+), 48 deletions(-) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index f476b0eb..584edefe 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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, })) +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) => { const translations: Record = { '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>) + + await act(async () => { + render() + }) + + 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([]) + }) }) diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 94af3ba3..b4a4ca2a 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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).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(null) + const [pendingCloseRequest, setPendingCloseRequest] = useState(null) const [dragOverIndex, setDragOverIndex] = useState(null) const [draggingSessionId, setDraggingSessionId] = useState(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() { )} - {closingTabId && ( + {pendingCloseRequest && (
-

{t('tabs.closeConfirmTitle')}

-

{t('tabs.closeConfirmMessage')}

+

+ {pendingCloseRequest.runningSessionIds.length > 1 + ? t('tabs.closeAllConfirmTitle') + : t('tabs.closeConfirmTitle')} +

+

+ {pendingCloseRequest.runningSessionIds.length > 1 + ? t('tabs.closeAllConfirmMessage', { count: pendingCloseRequest.runningSessionIds.length }) + : t('tabs.closeConfirmMessage')} +

-
diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index a59b5d94..b123b898 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 61fed16f..79c33349 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1499,6 +1499,9 @@ export const zh: Record = { 'tabs.closeConfirmMessage': '此会话仍在运行,你想要怎么做?', 'tabs.closeConfirmKeep': '保持运行', 'tabs.closeConfirmStop': '停止并关闭', + 'tabs.closeAllConfirmTitle': '多个会话运行中', + 'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?', + 'tabs.closeAllConfirmStop': '全部停止并关闭', 'tabs.openTerminal': '打开终端', 'tabs.showWorkspace': '显示工作区', 'tabs.hideWorkspace': '隐藏工作区',