From c8b867a634e0403d01ee49f8ef6b4357154d2b49 Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 30 Jan 2026 15:39:36 +0700 Subject: [PATCH] fix(frontend): persist selected agent mode across page refresh (planning agent) (#12672) --- .../conversation-local-storage.test.ts | 73 ++++++++++++++++++- .../hooks/use-handle-plan-click.test.tsx | 3 + .../stores/conversation-store.test.ts | 65 +++++++++++++++++ frontend/src/stores/conversation-store.ts | 31 +++++++- .../src/utils/conversation-local-storage.ts | 9 ++- 5 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 frontend/__tests__/stores/conversation-store.test.ts diff --git a/frontend/__tests__/conversation-local-storage.test.ts b/frontend/__tests__/conversation-local-storage.test.ts index 2f8cc21130..a99e5fc005 100644 --- a/frontend/__tests__/conversation-local-storage.test.ts +++ b/frontend/__tests__/conversation-local-storage.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { clearConversationLocalStorage, getConversationState, + isTaskConversationId, setConversationState, LOCAL_STORAGE_KEYS, } from "#/utils/conversation-local-storage"; @@ -11,6 +12,76 @@ describe("conversation localStorage utilities", () => { localStorage.clear(); }); + describe("isTaskConversationId", () => { + it("returns true for IDs starting with task-", () => { + expect(isTaskConversationId("task-abc-123")).toBe(true); + expect(isTaskConversationId("task-")).toBe(true); + }); + + it("returns false for normal conversation IDs", () => { + expect(isTaskConversationId("conv-123")).toBe(false); + expect(isTaskConversationId("abc")).toBe(false); + }); + }); + + describe("getConversationState", () => { + it("returns default state including conversationMode for task IDs without reading localStorage", () => { + const state = getConversationState("task-uuid-123"); + + expect(state.conversationMode).toBe("code"); + expect(state.selectedTab).toBe("editor"); + expect(state.rightPanelShown).toBe(true); + expect( + localStorage.getItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-uuid-123`, + ), + ).toBeNull(); + }); + + it("returns merged state from localStorage for real conversation ID including conversationMode", () => { + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-1`; + localStorage.setItem( + key, + JSON.stringify({ conversationMode: "plan", selectedTab: "terminal" }), + ); + + const state = getConversationState("conv-1"); + + expect(state.conversationMode).toBe("plan"); + expect(state.selectedTab).toBe("terminal"); + expect(state.rightPanelShown).toBe(true); + }); + + it("returns default state when key is missing or invalid", () => { + expect(getConversationState("conv-missing").conversationMode).toBe( + "code", + ); + + const key = `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-conv-bad`; + localStorage.setItem(key, "not json"); + expect(getConversationState("conv-bad").conversationMode).toBe("code"); + }); + }); + + describe("setConversationState", () => { + it("does not persist when conversationId is a task ID", () => { + setConversationState("task-xyz", { conversationMode: "plan" }); + + expect( + localStorage.getItem( + `${LOCAL_STORAGE_KEYS.CONVERSATION_STATE}-task-xyz`, + ), + ).toBeNull(); + }); + + it("persists conversationMode for real conversation ID and getConversationState returns it", () => { + setConversationState("conv-2", { conversationMode: "plan" }); + + const state = getConversationState("conv-2"); + expect(state.conversationMode).toBe("plan"); + }); + }); + describe("clearConversationLocalStorage", () => { it("removes the consolidated conversation-state localStorage entry", () => { const conversationId = "conv-123"; diff --git a/frontend/__tests__/hooks/use-handle-plan-click.test.tsx b/frontend/__tests__/hooks/use-handle-plan-click.test.tsx index b741d2f5de..067a208c81 100644 --- a/frontend/__tests__/hooks/use-handle-plan-click.test.tsx +++ b/frontend/__tests__/hooks/use-handle-plan-click.test.tsx @@ -87,6 +87,7 @@ describe("useHandlePlanClick", () => { rightPanelShown: true, unpinnedTabs: [], subConversationTaskId: null, + conversationMode: "code", }); }); @@ -115,6 +116,7 @@ describe("useHandlePlanClick", () => { rightPanelShown: true, unpinnedTabs: [], subConversationTaskId: storedTaskId, + conversationMode: "code", }); renderHook(() => useHandlePlanClick()); @@ -152,6 +154,7 @@ describe("useHandlePlanClick", () => { rightPanelShown: true, unpinnedTabs: [], subConversationTaskId: storedTaskId, + conversationMode: "code", }); renderHook(() => useHandlePlanClick()); diff --git a/frontend/__tests__/stores/conversation-store.test.ts b/frontend/__tests__/stores/conversation-store.test.ts new file mode 100644 index 0000000000..e811ae5a9b --- /dev/null +++ b/frontend/__tests__/stores/conversation-store.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useConversationStore } from "#/stores/conversation-store"; + +const defaultConversationState = { + selectedTab: "editor" as const, + rightPanelShown: true, + unpinnedTabs: [] as string[], + conversationMode: "code" as const, +}; + +const mockGetConversationState = vi.fn((_id: string) => defaultConversationState); +const mockSetConversationState = vi.fn(); + +vi.mock("#/utils/conversation-local-storage", () => ({ + getConversationState: (id: string) => mockGetConversationState(id), + setConversationState: (id: string, updates: object) => + mockSetConversationState(id, updates), +})); + +const CONV_ID = "conv-test-1"; + +describe("conversation store", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetConversationState.mockReturnValue(defaultConversationState); + Object.defineProperty(window, "location", { + value: { pathname: `/conversations/${CONV_ID}` }, + writable: true, + }); + useConversationStore.setState({ + conversationMode: "code", + planContent: null, + subConversationTaskId: null, + shouldHideSuggestions: false, + }); + }); + + describe("setConversationMode", () => { + it("updates store state and persists via setConversationState when conversation ID is in location", () => { + useConversationStore.getState().setConversationMode("plan"); + + expect(useConversationStore.getState().conversationMode).toBe("plan"); + expect(mockSetConversationState).toHaveBeenCalledWith(CONV_ID, { + conversationMode: "plan", + }); + }); + }); + + describe("resetConversationState", () => { + it("sets conversationMode from getConversationState", () => { + useConversationStore.setState({ conversationMode: "plan" }); + mockGetConversationState.mockReturnValue({ + selectedTab: "editor", + rightPanelShown: true, + unpinnedTabs: [], + conversationMode: "code", + }); + + useConversationStore.getState().resetConversationState(); + + expect(useConversationStore.getState().conversationMode).toBe("code"); + expect(mockGetConversationState).toHaveBeenCalledWith(CONV_ID); + }); + }); +}); diff --git a/frontend/src/stores/conversation-store.ts b/frontend/src/stores/conversation-store.ts index a8edd16f6a..94103d23c4 100644 --- a/frontend/src/stores/conversation-store.ts +++ b/frontend/src/stores/conversation-store.ts @@ -1,5 +1,9 @@ import { create } from "zustand"; import { devtools } from "zustand/middleware"; +import { + getConversationState, + setConversationState, +} from "#/utils/conversation-local-storage"; export type ConversationTab = | "editor" @@ -105,6 +109,20 @@ const getInitialRightPanelState = (): boolean => { return true; }; +const getInitialConversationMode = (): ConversationMode => { + if (typeof window === "undefined") { + return "code"; + } + + const conversationId = getConversationIdFromLocation(); + if (!conversationId) { + return "code"; + } + + const state = getConversationState(conversationId); + return state.conversationMode; +}; + export const useConversationStore = create()( devtools( (set) => ({ @@ -121,7 +139,7 @@ export const useConversationStore = create()( shouldHideSuggestions: false, hasRightPanelToggled: true, planContent: null, - conversationMode: "code", + conversationMode: getInitialConversationMode(), subConversationTaskId: null, // Actions @@ -257,7 +275,7 @@ export const useConversationStore = create()( set( { shouldHideSuggestions: false, - conversationMode: "code", + conversationMode: getInitialConversationMode(), subConversationTaskId: null, planContent: null, }, @@ -268,8 +286,13 @@ export const useConversationStore = create()( setHasRightPanelToggled: (hasRightPanelToggled) => set({ hasRightPanelToggled }, false, "setHasRightPanelToggled"), - setConversationMode: (conversationMode) => - set({ conversationMode }, false, "setConversationMode"), + setConversationMode: (conversationMode) => { + const conversationId = getConversationIdFromLocation(); + if (conversationId) { + setConversationState(conversationId, { conversationMode }); + } + set({ conversationMode }, false, "setConversationMode"); + }, setSubConversationTaskId: (subConversationTaskId) => set({ subConversationTaskId }, false, "setSubConversationTaskId"), diff --git a/frontend/src/utils/conversation-local-storage.ts b/frontend/src/utils/conversation-local-storage.ts index 9f09a59b60..639aa0f321 100644 --- a/frontend/src/utils/conversation-local-storage.ts +++ b/frontend/src/utils/conversation-local-storage.ts @@ -1,5 +1,8 @@ import { useState } from "react"; -import type { ConversationTab } from "#/stores/conversation-store"; +import type { + ConversationTab, + ConversationMode, +} from "#/stores/conversation-store"; export const LOCAL_STORAGE_KEYS = { CONVERSATION_STATE: "conversation-state", @@ -12,6 +15,7 @@ export interface ConversationState { selectedTab: ConversationTab | null; rightPanelShown: boolean; unpinnedTabs: string[]; + conversationMode: ConversationMode; subConversationTaskId: string | null; } @@ -19,6 +23,7 @@ const DEFAULT_CONVERSATION_STATE: ConversationState = { selectedTab: "editor", rightPanelShown: true, unpinnedTabs: [], + conversationMode: "code", subConversationTaskId: null, }; @@ -93,6 +98,7 @@ export function useConversationLocalStorageState(conversationId: string): { setSelectedTab: (tab: ConversationTab | null) => void; setRightPanelShown: (shown: boolean) => void; setUnpinnedTabs: (tabs: string[]) => void; + setConversationMode: (mode: ConversationMode) => void; } { const [state, setState] = useState(() => getConversationState(conversationId), @@ -108,5 +114,6 @@ export function useConversationLocalStorageState(conversationId: string): { setSelectedTab: (tab) => updateState({ selectedTab: tab }), setRightPanelShown: (shown) => updateState({ rightPanelShown: shown }), setUnpinnedTabs: (tabs) => updateState({ unpinnedTabs: tabs }), + setConversationMode: (mode) => updateState({ conversationMode: mode }), }; }