From bb74c9246d43ef385d4d5d7b23a33a6f7e0a22b8 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: Thu, 13 Aug 2026 22:54:05 +0800 Subject: [PATCH] fix(desktop): protect unloaded sessions from tab cleanup #1217 --- desktop/src/components/layout/TabBar.test.tsx | 116 ++++++++++++++++++ desktop/src/components/layout/TabBar.tsx | 11 +- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 56a66443..7440709b 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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>) + + await act(async () => { + render() + }) + + 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>) + + await act(async () => { + render() + }) + + 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>) + + await act(async () => { + render() + }) + + 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') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 8bfb593b..d1418238 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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)