diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 0dbdffdd6..fd6089671 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -28,7 +28,7 @@ import { import { socketService } from '../services/socketService'; import { store } from '../store'; import { resetUserScopedState } from '../store/resetActions'; -import { clearAllThreads, loadThreads } from '../store/threadSlice'; +import { loadThreads, resetThreadCachesPreservingSelection } from '../store/threadSlice'; import { getActiveUserId, setActiveUserId } from '../store/userScopedStorage'; import { openhumanUpdateAnalyticsSettings, @@ -257,7 +257,12 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) !isLogout ) { const threadReloadRequestId = requestId; - store.dispatch(clearAllThreads()); + // Reset the in-memory thread caches (rows from a pre-auth bucket — see + // #1157) but preserve the redux-persisted `selectedThreadId` so a + // reload of an already-authed user resumes the user's last-viewed + // thread (#1168). The Conversations mount effect falls back to "most + // recent" if the persisted id is no longer in the reloaded list. + store.dispatch(resetThreadCachesPreservingSelection()); void store .dispatch(loadThreads()) .unwrap() diff --git a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx index 9acf6e1e9..43f62d05d 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -153,9 +153,13 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { setActiveSpy.mockRestore(); }); - it('seed matches next user (no restart) but identity hydrates null→B: clears threads and reloads (#1157)', async () => { + it('seed matches next user (no restart) but identity hydrates null→B: resets thread caches (preserving selection) and reloads (#1157, #1168)', async () => { userScopedStorage.setActiveUserId('B'); fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'B', sessionToken: 'tokB' })); + // Seed a persisted selection that should survive the boot-time reset so + // the user resumes their last-viewed thread instead of falling through + // to "most recent". + store.dispatch(threadSlice.setSelectedThread('thr-persisted')); const dispatchSpy = vi.spyOn(store, 'dispatch'); const loadThreadsSpy = vi.spyOn(threadSlice, 'loadThreads'); @@ -173,10 +177,22 @@ describe('CoreStateProvider — identity flip cleanup (#900)', () => { expect( dispatchSpy.mock.calls.some(([action]) => { if (!action || typeof action !== 'object' || !('type' in action)) return false; - return (action as { type: string }).type === threadSlice.clearAllThreads().type; + return ( + (action as { type: string }).type === + threadSlice.resetThreadCachesPreservingSelection().type + ); }) ).toBe(true); + // The legacy `clearAllThreads` (which nulls selectedThreadId) must NOT + // be dispatched on this boot path — that was the #1168 regression. + expect( + dispatchSpy.mock.calls.some(([action]) => { + if (!action || typeof action !== 'object' || !('type' in action)) return false; + return (action as { type: string }).type === threadSlice.clearAllThreads().type; + }) + ).toBe(false); expect(loadThreadsSpy).toHaveBeenCalled(); + expect(store.getState().thread.selectedThreadId).toBe('thr-persisted'); dispatchSpy.mockRestore(); loadThreadsSpy.mockRestore(); diff --git a/app/src/store/threadSlice.ts b/app/src/store/threadSlice.ts index 5e3d7622e..0ed5adcf6 100644 --- a/app/src/store/threadSlice.ts +++ b/app/src/store/threadSlice.ts @@ -279,6 +279,19 @@ const threadSlice = createSlice({ state.activeThreadId = null; state.welcomeThreadId = null; }, + // Like `clearAllThreads` but keeps `selectedThreadId`. Used on the + // post-bootstrap identity-observation path (#1168 + #1157): we need to + // drop pre-auth in-memory thread rows but the persisted last-viewed + // thread id is still valid for the reloaded user, so preserving it lets + // the Conversations effect resume that thread instead of falling + // through to "most recent". + resetThreadCachesPreservingSelection: state => { + state.threads = []; + state.messagesByThreadId = {}; + state.messages = []; + state.activeThreadId = null; + state.welcomeThreadId = null; + }, // [#1123] True no-op — welcome-agent onboarding replaced by Joyride walkthrough. // Kept to avoid breaking existing imports; state.welcomeThreadId is never // mutated because the welcome-agent flow no longer runs. @@ -350,6 +363,7 @@ export const { clearSelectedThread, setActiveThread, clearAllThreads, + resetThreadCachesPreservingSelection, setWelcomeThreadId, } = threadSlice.actions;