From 58f19d4ddc8739c206bcd0183d96c066762e5189 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:47:25 +0800 Subject: [PATCH] fix: prevent chat sessions from disappearing on switch and add loading timeout safety net (#572) --- src/stores/chat.ts | 42 +++++++++++++++++++++---- src/stores/chat/session-actions.ts | 27 ++++++++++++---- tests/unit/chat-session-actions.test.ts | 29 ++++++++++++++--- 3 files changed, 82 insertions(+), 16 deletions(-) diff --git a/src/stores/chat.ts b/src/stores/chat.ts index c5591e9dc..aa7b36f30 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -779,7 +779,14 @@ function buildSessionSwitchPatch( >, nextSessionKey: string, ): Partial { - const leavingEmpty = !state.currentSessionKey.endsWith(':main') && state.messages.length === 0; + // 仅将没有任何历史记录且无活动时间的会话视为空会话。 + // 单纯依赖 messages.length 是不可靠的,因为 switchSession 会在真正调用 loadHistory 前抢先清空当前 messages, + // 造成竞争条件,使得带有真实历史的会话被判定为空并从侧边栏移除。 + const leavingEmpty = !state.currentSessionKey.endsWith(':main') + && state.messages.length === 0 + && !state.sessionLastActivity[state.currentSessionKey] + && !state.sessionLabels[state.currentSessionKey]; + const nextSessions = leavingEmpty ? state.sessions.filter((session) => session.key !== state.currentSessionKey) : state.sessions; @@ -1302,8 +1309,12 @@ export const useChatStore = create((set, get) => ({ // NOTE: We intentionally do NOT call sessions.reset on the old session. // sessions.reset archives (renames) the session JSONL file, making old // conversation history inaccessible when the user switches back to it. - const { currentSessionKey, messages, sessions } = get(); - const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; + const { currentSessionKey, messages, sessions, sessionLastActivity, sessionLabels } = get(); + // 仅将没有任何历史记录且无活动时间的会话视为空会话 + const leavingEmpty = !currentSessionKey.endsWith(':main') + && messages.length === 0 + && !sessionLastActivity[currentSessionKey] + && !sessionLabels[currentSessionKey]; const prefix = getCanonicalPrefixFromSessionKey(currentSessionKey) ?? getCanonicalPrefixFromSessions(sessions) ?? DEFAULT_CANONICAL_PREFIX; @@ -1337,12 +1348,17 @@ export const useChatStore = create((set, get) => ({ // ── Cleanup empty session on navigate away ── cleanupEmptySession: () => { - const { currentSessionKey, messages } = get(); + const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); // Only remove non-main sessions that were never used (no messages sent). // This mirrors the "leavingEmpty" logic in switchSession so that creating // a new session and immediately navigating away doesn't leave a ghost entry // in the sidebar. - const isEmptyNonMain = !currentSessionKey.endsWith(':main') && messages.length === 0; + // 同样需要综合检查 sessionLastActivity 和 sessionLabels, + // 防止因为 switchSession 抢先清空 messages 而误判有历史的会话为空。 + const isEmptyNonMain = !currentSessionKey.endsWith(':main') + && messages.length === 0 + && !sessionLastActivity[currentSessionKey] + && !sessionLabels[currentSessionKey]; if (!isEmptyNonMain) return; set((s) => ({ sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey), @@ -1372,6 +1388,14 @@ export const useChatStore = create((set, get) => ({ if (!quiet) set({ loading: true, error: null }); + // 安全保护:如果历史记录加载花费太多时间,则强制将 loading 设置为 false + // 防止 UI 永远卡在转圈状态。 + let loadingTimedOut = false; + const loadingSafetyTimer = quiet ? null : setTimeout(() => { + loadingTimedOut = true; + set({ loading: false }); + }, 15_000); + const loadPromise = (async () => { const applyLoadedMessages = (rawMessages: RawMessage[], thinkingLevel: string | null) => { // Before filtering: attach images/files from tool_result messages to the next assistant message @@ -1515,7 +1539,13 @@ export const useChatStore = create((set, get) => ({ try { await loadPromise; } finally { - _lastHistoryLoadAtBySession.set(currentSessionKey, Date.now()); + // 正常完成时清除安全定时器 + if (loadingSafetyTimer) clearTimeout(loadingSafetyTimer); + if (!loadingTimedOut) { + // Only update load time if we actually didn't time out + _lastHistoryLoadAtBySession.set(currentSessionKey, Date.now()); + } + const active = _historyLoadInFlight.get(currentSessionKey); if (active === loadPromise) { _historyLoadInFlight.delete(currentSessionKey); diff --git a/src/stores/chat/session-actions.ts b/src/stores/chat/session-actions.ts index bca90d2fc..601005725 100644 --- a/src/stores/chat/session-actions.ts +++ b/src/stores/chat/session-actions.ts @@ -153,8 +153,14 @@ export function createSessionActions( // ── Switch session ── switchSession: (key: string) => { - const { currentSessionKey, messages } = get(); - const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; + const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); + // 仅将没有任何历史记录且无活动时间的会话视为空会话。 + // 单纯依赖 messages.length 是不可靠的,因为 switchSession 会在真正调用 loadHistory 前抢先清空当前 messages, + // 造成竞争条件,使得带有真实历史的会话被判定为空并从侧边栏移除。 + const leavingEmpty = !currentSessionKey.endsWith(':main') + && messages.length === 0 + && !sessionLastActivity[currentSessionKey] + && !sessionLabels[currentSessionKey]; set((s) => ({ currentSessionKey: key, currentAgentId: getAgentIdFromSessionKey(key), @@ -246,8 +252,12 @@ export function createSessionActions( // NOTE: We intentionally do NOT call sessions.reset on the old session. // sessions.reset archives (renames) the session JSONL file, making old // conversation history inaccessible when the user switches back to it. - const { currentSessionKey, messages } = get(); - const leavingEmpty = !currentSessionKey.endsWith(':main') && messages.length === 0; + const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); + // 仅将没有任何历史记录且无活动时间的会话视为空会话 + const leavingEmpty = !currentSessionKey.endsWith(':main') + && messages.length === 0 + && !sessionLastActivity[currentSessionKey] + && !sessionLabels[currentSessionKey]; const prefix = getCanonicalPrefixFromSessions(get().sessions) ?? DEFAULT_CANONICAL_PREFIX; const newKey = `${prefix}:session-${Date.now()}`; const newSessionEntry: ChatSession = { key: newKey, displayName: newKey }; @@ -279,12 +289,17 @@ export function createSessionActions( // ── Cleanup empty session on navigate away ── cleanupEmptySession: () => { - const { currentSessionKey, messages } = get(); + const { currentSessionKey, messages, sessionLastActivity, sessionLabels } = get(); // Only remove non-main sessions that were never used (no messages sent). // This mirrors the "leavingEmpty" logic in switchSession so that creating // a new session and immediately navigating away doesn't leave a ghost entry // in the sidebar. - const isEmptyNonMain = !currentSessionKey.endsWith(':main') && messages.length === 0; + // 同样需要综合检查 sessionLastActivity 和 sessionLabels, + // 防止因为 switchSession 抢先清空 messages 而误判有历史的会话为空。 + const isEmptyNonMain = !currentSessionKey.endsWith(':main') + && messages.length === 0 + && !sessionLastActivity[currentSessionKey] + && !sessionLabels[currentSessionKey]; if (!isEmptyNonMain) return; set((s) => ({ sessions: s.sessions.filter((sess) => sess.key !== currentSessionKey), diff --git a/tests/unit/chat-session-actions.test.ts b/tests/unit/chat-session-actions.test.ts index 7c1d5574a..8dadfb710 100644 --- a/tests/unit/chat-session-actions.test.ts +++ b/tests/unit/chat-session-actions.test.ts @@ -55,7 +55,7 @@ describe('chat session actions', () => { invokeIpcMock.mockResolvedValue({ success: true }); }); - it('switchSession removes empty non-main leaving session and loads history', async () => { + it('switchSession preserves non-main session that has activity history', async () => { const { createSessionActions } = await import('@/stores/chat/session-actions'); const h = makeHarness({ currentSessionKey: 'agent:foo:session-a', @@ -69,9 +69,30 @@ describe('chat session actions', () => { actions.switchSession('agent:foo:main'); const next = h.read(); expect(next.currentSessionKey).toBe('agent:foo:main'); - expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeUndefined(); - expect(next.sessionLabels['agent:foo:session-a']).toBeUndefined(); - expect(next.sessionLastActivity['agent:foo:session-a']).toBeUndefined(); + // Session with labels and activity should NOT be removed even though messages is empty, + // because messages get cleared eagerly during switchSession before loadHistory completes. + expect(next.sessions.find((s) => s.key === 'agent:foo:session-a')).toBeDefined(); + expect(next.sessionLabels['agent:foo:session-a']).toBe('A'); + expect(next.sessionLastActivity['agent:foo:session-a']).toBe(1); + expect(h.read().loadHistory).toHaveBeenCalledTimes(1); + }); + + it('switchSession removes truly empty non-main session (no activity, no labels)', async () => { + const { createSessionActions } = await import('@/stores/chat/session-actions'); + const h = makeHarness({ + currentSessionKey: 'agent:foo:session-b', + sessions: [{ key: 'agent:foo:session-b' }, { key: 'agent:foo:main' }], + messages: [], + sessionLabels: {}, + sessionLastActivity: {}, + }); + const actions = createSessionActions(h.set as never, h.get as never); + + actions.switchSession('agent:foo:main'); + const next = h.read(); + expect(next.currentSessionKey).toBe('agent:foo:main'); + // Truly empty session (no labels, no activity) should be cleaned up + expect(next.sessions.find((s) => s.key === 'agent:foo:session-b')).toBeUndefined(); expect(h.read().loadHistory).toHaveBeenCalledTimes(1); });