fix(desktop): protect unloaded sessions from tab cleanup #1217

This commit is contained in:
程序员阿江(Relakkes)
2026-08-13 22:54:05 +08:00
parent 8673f2092a
commit bb74c9246d
2 changed files with 125 additions and 2 deletions
@@ -2167,6 +2167,122 @@ describe('TabBar', () => {
expect(useTabStore.getState().activeTabId).toBe('tab-2')
})
it('does not delete a session when its list and history failed to load', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
useTabStore.setState({
tabs: [
{ sessionId: 'recovered-session', title: 'Recovered Session', type: 'session', status: 'idle' },
],
activeTabId: 'recovered-session',
})
useSessionStore.setState({
sessions: [],
isLoading: false,
error: 'Session list request timed out',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close Recovered Session'))
expect(sessionsApiMock.delete).not.toHaveBeenCalled()
expect(useTabStore.getState().tabs).toEqual([])
})
it('does not delete a session whose server metadata reports messages', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const sessionId = 'persisted-session'
useTabStore.setState({
tabs: [{ sessionId, title: 'New Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'New Session',
createdAt: '2026-08-13T00:00:00.000Z',
modifiedAt: '2026-08-13T00:00:00.000Z',
messageCount: 2,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
}],
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[sessionId]: { ...makeChatSession('idle'), historyStatus: 'ready' },
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close New Session'))
expect(sessionsApiMock.delete).not.toHaveBeenCalled()
})
it('deletes a confirmed empty placeholder session when closing its tab', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const { useSessionStore } = await import('../../stores/sessionStore')
const sessionId = 'empty-session'
useTabStore.setState({
tabs: [{ sessionId, title: 'New Session', type: 'session', status: 'idle' }],
activeTabId: sessionId,
})
useSessionStore.setState({
sessions: [{
id: sessionId,
title: 'New Session',
createdAt: '2026-08-13T00:00:00.000Z',
modifiedAt: '2026-08-13T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
workDir: '/repo',
workDirExists: true,
}],
isLoading: false,
error: null,
})
useChatStore.setState({
sessions: {
[sessionId]: { ...makeChatSession('idle'), historyStatus: 'ready' },
},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
fireEvent.click(screen.getByLabelText('Close New Session'))
await waitFor(() => {
expect(sessionsApiMock.delete).toHaveBeenCalledWith(sessionId)
})
})
it('closes terminal tabs without disconnecting chat sessions', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
+9 -2
View File
@@ -367,10 +367,17 @@ export function TabBar() {
useChatStore.getState().stopGeneration(tab.sessionId)
}
if (!isRunning || stopRunning) {
// Auto-delete empty sessions (placeholder title, no messages sent)
// Auto-delete only when both server metadata and the loaded transcript
// confirm this is an empty placeholder. Missing state can mean a timed-out
// recovery load, so treating it as empty risks deleting a real session.
const sessionEntry = useSessionStore.getState().sessions.find((s) => s.id === tab.sessionId)
const chatEntry = useChatStore.getState().sessions[tab.sessionId]
if (isPlaceholderSessionTitle(sessionEntry?.title) && (!chatEntry || chatEntry.messages.length === 0)) {
if (
sessionEntry?.messageCount === 0 &&
isPlaceholderSessionTitle(sessionEntry.title) &&
chatEntry?.historyStatus === 'ready' &&
chatEntry.messages.length === 0
) {
void useSessionStore.getState().deleteSession(tab.sessionId)
}
disconnectSession(tab.sessionId)