mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Set up Redux, extract component state into Redux store, and break WebSocket out of Terminal component only (#85)
This commit is contained in:
parent
fabdddca21
commit
a2f245e2fe
@ -18,10 +18,18 @@
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {
|
||||
// Allow state modification in Redux reducers
|
||||
"no-param-reassign": ["error", {
|
||||
"props": true,
|
||||
"ignorePropertyModificationsFor": [
|
||||
"state"
|
||||
]
|
||||
}],
|
||||
"no-underscore-dangle": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"react/no-array-index-key": "off"
|
||||
"react/no-array-index-key": "off",
|
||||
|
||||
},"parserOptions": {
|
||||
"project": ["**/tsconfig.json"]
|
||||
}
|
||||
|
||||
91
frontend/package-lock.json
generated
91
frontend/package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.2.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@ -21,6 +22,7 @@
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.1.6",
|
||||
@ -1731,6 +1733,29 @@
|
||||
"url": "https://opencollective.com/unts"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.2.2.tgz",
|
||||
"integrity": "sha512-454GZrEx3G6QSYwIx9ROaso1HR6sTH8qyZBe3KEsdWVGU3ayV8jYCwdaEJV3vl9V6+pi3GRl+7Xl7AeDna6qwQ==",
|
||||
"dependencies": {
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz",
|
||||
@ -2240,6 +2265,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
|
||||
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
|
||||
"integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA=="
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
|
||||
@ -5178,6 +5208,15 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.0.4.tgz",
|
||||
"integrity": "sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
|
||||
@ -8002,6 +8041,32 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.0.tgz",
|
||||
"integrity": "sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.3",
|
||||
"use-sync-external-store": "^1.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25",
|
||||
"react": "^18.0",
|
||||
"react-native": ">=0.69",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz",
|
||||
@ -8037,6 +8102,19 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz",
|
||||
@ -8117,6 +8195,11 @@
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz",
|
||||
"integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg=="
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
@ -9150,6 +9233,14 @@
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz",
|
||||
"integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz",
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@reduxjs/toolkit": "^2.2.2",
|
||||
"@testing-library/jest-dom": "^5.17.0",
|
||||
"@testing-library/react": "^13.4.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
@ -17,6 +18,7 @@
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-redux": "^9.1.0",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.1.6",
|
||||
|
||||
@ -23,33 +23,27 @@ function Tab({ name, active, onClick }: TabProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
const tabData = {
|
||||
terminal: {
|
||||
name: "Terminal",
|
||||
component: <Terminal />,
|
||||
},
|
||||
planner: {
|
||||
name: "Planner",
|
||||
component: <Planner />,
|
||||
},
|
||||
code: {
|
||||
name: "Code Editor",
|
||||
component: <CodeEditor />,
|
||||
},
|
||||
browser: {
|
||||
name: "Browser",
|
||||
component: <Browser />,
|
||||
},
|
||||
};
|
||||
|
||||
function App(): JSX.Element {
|
||||
const [activeTab, setActiveTab] = useState<TabOption>("terminal");
|
||||
// URL of browser window (placeholder for now, will be replaced with the actual URL later)
|
||||
const [url] = useState("https://github.com/OpenDevin/OpenDevin");
|
||||
// Base64-encoded screenshot of browser window (placeholder for now, will be replaced with the actual screenshot later)
|
||||
const [screenshotSrc] = useState(
|
||||
"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
|
||||
);
|
||||
|
||||
const tabData = {
|
||||
terminal: {
|
||||
name: "Terminal",
|
||||
component: <Terminal />,
|
||||
},
|
||||
planner: {
|
||||
name: "Planner",
|
||||
component: <Planner />,
|
||||
},
|
||||
code: {
|
||||
name: "Code Editor",
|
||||
component: <CodeEditor />,
|
||||
},
|
||||
browser: {
|
||||
name: "Browser",
|
||||
component: <Browser url={url} screenshotSrc={screenshotSrc} />,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import "./Browser.css";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "../store";
|
||||
|
||||
type UrlBarProps = {
|
||||
url: string;
|
||||
@ -17,12 +19,12 @@ function Screenshot({ src }: ScreenshotProps): JSX.Element {
|
||||
return <img className="screenshot" src={src} alt="screenshot" />;
|
||||
}
|
||||
|
||||
type BrowserProps = {
|
||||
url: string;
|
||||
screenshotSrc: string;
|
||||
};
|
||||
function Browser(): JSX.Element {
|
||||
const url = useSelector((state: RootState) => state.browser.url);
|
||||
const screenshotSrc = useSelector(
|
||||
(state: RootState) => state.browser.screenshotSrc,
|
||||
);
|
||||
|
||||
function Browser({ url, screenshotSrc }: BrowserProps): JSX.Element {
|
||||
return (
|
||||
<div className="browser">
|
||||
<UrlBar url={url} />
|
||||
|
||||
@ -1,42 +1,19 @@
|
||||
import React, { useState } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import "./ChatInterface.css";
|
||||
import userAvatar from "../assets/user-avatar.png";
|
||||
import assistantAvatar from "../assets/assistant-avatar.png";
|
||||
|
||||
interface Message {
|
||||
content: string;
|
||||
sender: "user" | "assistant";
|
||||
}
|
||||
import { sendMessage } from "../state/chatSlice";
|
||||
import { RootState } from "../store";
|
||||
|
||||
function ChatInterface(): JSX.Element {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
content:
|
||||
"I want you to setup this project: https://github.com/mckaywrigley/assistant-ui",
|
||||
sender: "user",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"Got it, I'll get started on setting up the assistant UI project from the GitHub link you provided. I'll update you on my progress.",
|
||||
sender: "assistant",
|
||||
},
|
||||
{ content: "Cloned repo from GitHub.", sender: "assistant" },
|
||||
{ content: "You're doing great! Keep it up :)", sender: "user" },
|
||||
{
|
||||
content:
|
||||
"Thanks! I've cloned the repo and am currently going through the README to make sure we get everything set up right. There's a detailed guide for local setup as well as instructions for hosting it. I'll follow the steps and keep you posted on the progress! If there are any specific configurations or features you want to prioritize, just let me know.",
|
||||
sender: "assistant",
|
||||
},
|
||||
{
|
||||
content: "Installed project dependencies using npm.",
|
||||
sender: "assistant",
|
||||
},
|
||||
]);
|
||||
const messages = useSelector((state: RootState) => state.chat.messages);
|
||||
const [inputMessage, setInputMessage] = useState("");
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleSendMessage = () => {
|
||||
if (inputMessage.trim() !== "") {
|
||||
setMessages([...messages, { content: inputMessage, sender: "user" }]);
|
||||
dispatch(sendMessage(inputMessage));
|
||||
setInputMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,14 +2,15 @@ import React, { useEffect, useRef } from "react";
|
||||
import { IDisposable, Terminal as XtermTerminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "xterm-addon-fit";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import socket from "../state/socket";
|
||||
|
||||
class JsonWebsocketAddon {
|
||||
_socket: WebSocket;
|
||||
|
||||
_disposables: IDisposable[];
|
||||
|
||||
constructor(socket: WebSocket) {
|
||||
this._socket = socket;
|
||||
constructor(_socket: WebSocket) {
|
||||
this._socket = _socket;
|
||||
this._disposables = [];
|
||||
}
|
||||
|
||||
@ -36,7 +37,7 @@ class JsonWebsocketAddon {
|
||||
|
||||
function Terminal(): JSX.Element {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const WS_URL = import.meta.env.VITE_TERMINAL_WS_URL;
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = new XtermTerminal({
|
||||
// This value is set to the appropriate value by the
|
||||
@ -60,12 +61,6 @@ function Terminal(): JSX.Element {
|
||||
fitAddon.fit();
|
||||
}, 1);
|
||||
|
||||
if (!WS_URL) {
|
||||
throw new Error(
|
||||
"The environment variable VITE_TERMINAL_WS_URL is not set. Please set it to the WebSocket URL of the terminal server.",
|
||||
);
|
||||
}
|
||||
const socket = new WebSocket(WS_URL as string);
|
||||
const jsonWebsocketAddon = new JsonWebsocketAddon(socket);
|
||||
terminal.loadAddon(jsonWebsocketAddon);
|
||||
|
||||
|
||||
@ -1,15 +1,19 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { Provider } from "react-redux";
|
||||
import App from "./App";
|
||||
import reportWebVitals from "./reportWebVitals";
|
||||
import store from "./store";
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById("root") as HTMLElement,
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<App />
|
||||
</Provider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
|
||||
24
frontend/src/state/browserSlice.ts
Normal file
24
frontend/src/state/browserSlice.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
export const browserSlice = createSlice({
|
||||
name: "browser",
|
||||
initialState: {
|
||||
// URL of browser window (placeholder for now, will be replaced with the actual URL later)
|
||||
url: "https://github.com/OpenDevin/OpenDevin",
|
||||
// Base64-encoded screenshot of browser window (placeholder for now, will be replaced with the actual screenshot later)
|
||||
screenshotSrc:
|
||||
"data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
|
||||
},
|
||||
reducers: {
|
||||
setUrl: (state, action) => {
|
||||
state.url = action.payload;
|
||||
},
|
||||
setScreenshotSrc: (state, action) => {
|
||||
state.screenshotSrc = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUrl, setScreenshotSrc } = browserSlice.actions;
|
||||
|
||||
export default browserSlice.reducer;
|
||||
46
frontend/src/state/chatSlice.ts
Normal file
46
frontend/src/state/chatSlice.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
type Message = {
|
||||
content: string;
|
||||
sender: "user" | "assistant";
|
||||
};
|
||||
|
||||
const initialMessages: Message[] = [
|
||||
{
|
||||
content:
|
||||
"I want you to setup this project: https://github.com/mckaywrigley/assistant-ui",
|
||||
sender: "user",
|
||||
},
|
||||
{
|
||||
content:
|
||||
"Got it, I'll get started on setting up the assistant UI project from the GitHub link you provided. I'll update you on my progress.",
|
||||
sender: "assistant",
|
||||
},
|
||||
{ content: "Cloned repo from GitHub.", sender: "assistant" },
|
||||
{ content: "You're doing great! Keep it up :)", sender: "user" },
|
||||
{
|
||||
content:
|
||||
"Thanks! I've cloned the repo and am currently going through the README to make sure we get everything set up right. There's a detailed guide for local setup as well as instructions for hosting it. I'll follow the steps and keep you posted on the progress! If there are any specific configurations or features you want to prioritize, just let me know.",
|
||||
sender: "assistant",
|
||||
},
|
||||
{
|
||||
content: "Installed project dependencies using npm.",
|
||||
sender: "assistant",
|
||||
},
|
||||
];
|
||||
|
||||
export const chatSlice = createSlice({
|
||||
name: "chat",
|
||||
initialState: {
|
||||
messages: initialMessages,
|
||||
},
|
||||
reducers: {
|
||||
sendMessage: (state, action) => {
|
||||
state.messages.push({ content: action.payload, sender: "user" });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { sendMessage } = chatSlice.actions;
|
||||
|
||||
export default chatSlice.reducer;
|
||||
42
frontend/src/state/socket.ts
Normal file
42
frontend/src/state/socket.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import store from "../store";
|
||||
import { setScreenshotSrc, setUrl } from "./browserSlice";
|
||||
|
||||
const MESSAGE_ACTIONS = ["terminal", "planner", "code", "browser"] as const;
|
||||
type MessageAction = (typeof MESSAGE_ACTIONS)[number];
|
||||
|
||||
type SocketMessage = {
|
||||
action: MessageAction;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const messageActions = {
|
||||
browser: (message: SocketMessage) => {
|
||||
const { url, screenshotSrc } = message.data;
|
||||
store.dispatch(setUrl(url));
|
||||
store.dispatch(setScreenshotSrc(screenshotSrc));
|
||||
},
|
||||
terminal: () => {},
|
||||
planner: () => {},
|
||||
code: () => {},
|
||||
};
|
||||
|
||||
const WS_URL = import.meta.env.VITE_TERMINAL_WS_URL;
|
||||
if (!WS_URL) {
|
||||
throw new Error(
|
||||
"The environment variable VITE_TERMINAL_WS_URL is not set. Please set it to the WebSocket URL of the terminal server.",
|
||||
);
|
||||
}
|
||||
|
||||
const socket = new WebSocket(WS_URL);
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const { message } = JSON.parse(event.data);
|
||||
console.log("Received message:", message);
|
||||
|
||||
if (message.action in messageActions) {
|
||||
const action = messageActions[message.action as MessageAction];
|
||||
action(message);
|
||||
}
|
||||
});
|
||||
|
||||
export default socket;
|
||||
15
frontend/src/store.ts
Normal file
15
frontend/src/store.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import browserReducer from "./state/browserSlice";
|
||||
import chatReducer from "./state/chatSlice";
|
||||
|
||||
const store = configureStore({
|
||||
reducer: {
|
||||
browser: browserReducer,
|
||||
chat: chatReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export default store;
|
||||
Loading…
x
Reference in New Issue
Block a user