From 88cd16ae2193c95cff7e7b08b61f724c3a04349c Mon Sep 17 00:00:00 2001 From: Hiep Le <69354317+hieptl@users.noreply.github.com> Date: Fri, 19 Sep 2025 22:27:20 +0700 Subject: [PATCH] refactor(frontend): migration of initial-query-slice.ts to zustand (#11020) --- frontend/__tests__/initial-query.test.tsx | 26 +++--- frontend/package-lock.json | 32 ++++++- frontend/package.json | 3 +- .../features/chat/chat-interface.tsx | 5 +- frontend/src/state/initial-query-slice.ts | 62 -------------- frontend/src/store.ts | 2 - frontend/src/stores/initial-query-store.ts | 85 +++++++++++++++++++ 7 files changed, 135 insertions(+), 80 deletions(-) delete mode 100644 frontend/src/state/initial-query-slice.ts create mode 100644 frontend/src/stores/initial-query-store.ts diff --git a/frontend/__tests__/initial-query.test.tsx b/frontend/__tests__/initial-query.test.tsx index 8249217529..f6c56f26cc 100644 --- a/frontend/__tests__/initial-query.test.tsx +++ b/frontend/__tests__/initial-query.test.tsx @@ -1,20 +1,24 @@ -import { describe, it, expect } from "vitest"; -import store from "../src/store"; -import { - setInitialPrompt, - clearInitialPrompt, -} from "../src/state/initial-query-slice"; +import { describe, it, expect, beforeEach } from "vitest"; +import { useInitialQueryStore } from "../src/stores/initial-query-store"; describe("Initial Query Behavior", () => { - it("should clear initial query when clearInitialPrompt is dispatched", () => { + beforeEach(() => { + // Reset the store before each test + useInitialQueryStore.getState().reset(); + }); + + it("should clear initial query when clearInitialPrompt is called", () => { + const { setInitialPrompt, clearInitialPrompt, initialPrompt } = + useInitialQueryStore.getState(); + // Set up initial query in the store - store.dispatch(setInitialPrompt("test query")); - expect(store.getState().initialQuery.initialPrompt).toBe("test query"); + setInitialPrompt("test query"); + expect(useInitialQueryStore.getState().initialPrompt).toBe("test query"); // Clear the initial query - store.dispatch(clearInitialPrompt()); + clearInitialPrompt(); // Verify initial query is cleared - expect(store.getState().initialQuery.initialPrompt).toBeNull(); + expect(useInitialQueryStore.getState().initialPrompt).toBeNull(); }); }); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6bd11dbece..c2bd14252a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -59,7 +59,8 @@ "tailwind-scrollbar": "^4.0.2", "vite": "^7.1.4", "web-vitals": "^5.1.0", - "ws": "^8.18.2" + "ws": "^8.18.2", + "zustand": "^5.0.8" }, "devDependencies": { "@babel/parser": "^7.28.3", @@ -18326,6 +18327,35 @@ "dev": true, "license": "MIT" }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 951fa79e63..5027e24c8e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -58,7 +58,8 @@ "tailwind-scrollbar": "^4.0.2", "vite": "^7.1.4", "web-vitals": "^5.1.0", - "ws": "^8.18.2" + "ws": "^8.18.2", + "zustand": "^5.0.8" }, "scripts": { "dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev", diff --git a/frontend/src/components/features/chat/chat-interface.tsx b/frontend/src/components/features/chat/chat-interface.tsx index ccbc46d406..2799adf31f 100644 --- a/frontend/src/components/features/chat/chat-interface.tsx +++ b/frontend/src/components/features/chat/chat-interface.tsx @@ -18,6 +18,7 @@ import { useWsClient } from "#/context/ws-client-provider"; import { Messages } from "./messages"; import { ChatSuggestions } from "./chat-suggestions"; import { ScrollProvider } from "#/context/scroll-context"; +import { useInitialQueryStore } from "#/stores/initial-query-store"; import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button"; import { LoadingSpinner } from "#/components/shared/loading-spinner"; @@ -67,9 +68,7 @@ export function ChatInterface() { "positive" | "negative" >("positive"); const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false); - const { selectedRepository, replayJson } = useSelector( - (state: RootState) => state.initialQuery, - ); + const { selectedRepository, replayJson } = useInitialQueryStore(); const params = useParams(); const { mutateAsync: uploadFiles } = useUploadFiles(); diff --git a/frontend/src/state/initial-query-slice.ts b/frontend/src/state/initial-query-slice.ts deleted file mode 100644 index 14e60f49ab..0000000000 --- a/frontend/src/state/initial-query-slice.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { Provider } from "#/types/settings"; -import { GitRepository } from "#/types/git"; - -type SliceState = { - files: string[]; // base64 encoded images - initialPrompt: string | null; - selectedRepository: GitRepository | null; - selectedRepositoryProvider: Provider | null; - replayJson: string | null; -}; - -const initialState: SliceState = { - files: [], - initialPrompt: null, - selectedRepository: null, - selectedRepositoryProvider: null, - replayJson: null, -}; - -export const selectedFilesSlice = createSlice({ - name: "initialQuery", - initialState, - reducers: { - addFile(state, action: PayloadAction) { - state.files.push(action.payload); - }, - removeFile(state, action: PayloadAction) { - state.files.splice(action.payload, 1); - }, - clearFiles(state) { - state.files = []; - }, - setInitialPrompt(state, action: PayloadAction) { - state.initialPrompt = action.payload; - }, - clearInitialPrompt(state) { - state.initialPrompt = null; - }, - setSelectedRepository(state, action: PayloadAction) { - state.selectedRepository = action.payload; - }, - clearSelectedRepository(state) { - state.selectedRepository = null; - }, - setReplayJson(state, action: PayloadAction) { - state.replayJson = action.payload; - }, - }, -}); - -export const { - addFile, - removeFile, - clearFiles, - setInitialPrompt, - clearInitialPrompt, - setSelectedRepository, - clearSelectedRepository, - setReplayJson, -} = selectedFilesSlice.actions; -export default selectedFilesSlice.reducer; diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 3eb535e774..92985351e9 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -2,7 +2,6 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit"; import agentReducer from "./state/agent-slice"; import browserReducer from "./state/browser-slice"; import fileStateReducer from "./state/file-state-slice"; -import initialQueryReducer from "./state/initial-query-slice"; import commandReducer from "./state/command-slice"; import { jupyterReducer } from "./state/jupyter-slice"; import securityAnalyzerReducer from "./state/security-analyzer-slice"; @@ -14,7 +13,6 @@ import eventMessageReducer from "./state/event-message-slice"; export const rootReducer = combineReducers({ fileState: fileStateReducer, - initialQuery: initialQueryReducer, browser: browserReducer, cmd: commandReducer, agent: agentReducer, diff --git a/frontend/src/stores/initial-query-store.ts b/frontend/src/stores/initial-query-store.ts new file mode 100644 index 0000000000..428fb08223 --- /dev/null +++ b/frontend/src/stores/initial-query-store.ts @@ -0,0 +1,85 @@ +import { create } from "zustand"; +import { Provider } from "#/types/settings"; +import { GitRepository } from "#/types/git"; + +interface InitialQueryState { + files: string[]; // base64 encoded images + initialPrompt: string | null; + selectedRepository: GitRepository | null; + selectedRepositoryProvider: Provider | null; + replayJson: string | null; +} + +interface InitialQueryActions { + addFile: (file: string) => void; + removeFile: (index: number) => void; + clearFiles: () => void; + setInitialPrompt: (prompt: string) => void; + clearInitialPrompt: () => void; + setSelectedRepository: (repository: GitRepository | null) => void; + clearSelectedRepository: () => void; + setSelectedRepositoryProvider: (provider: Provider | null) => void; + setReplayJson: (replayJson: string | null) => void; + reset: () => void; +} + +type InitialQueryStore = InitialQueryState & InitialQueryActions; + +const initialState: InitialQueryState = { + files: [], + initialPrompt: null, + selectedRepository: null, + selectedRepositoryProvider: null, + replayJson: null, +}; + +export const useInitialQueryStore = create((set) => ({ + ...initialState, + + addFile: (file: string) => + set((state) => ({ + files: [...state.files, file], + })), + + removeFile: (index: number) => + set((state) => ({ + files: state.files.filter((_, i) => i !== index), + })), + + clearFiles: () => + set(() => ({ + files: [], + })), + + setInitialPrompt: (prompt: string) => + set(() => ({ + initialPrompt: prompt, + })), + + clearInitialPrompt: () => + set(() => ({ + initialPrompt: null, + })), + + setSelectedRepository: (repository: GitRepository | null) => + set(() => ({ + selectedRepository: repository, + })), + + clearSelectedRepository: () => + set(() => ({ + selectedRepository: null, + })), + + setSelectedRepositoryProvider: (provider: Provider | null) => + set(() => ({ + selectedRepositoryProvider: provider, + })), + + setReplayJson: (replayJson: string | null) => + set(() => ({ + replayJson, + })), + + reset: () => set(() => initialState), +}));