mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
82609f6db6
commit
5f97f9ac62
@ -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([])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '隐藏工作区',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user