Set up Redux, extract component state into Redux store, and break WebSocket out of Terminal component only (#85)

This commit is contained in:
Jim Su 2024-03-23 17:40:43 -04:00 committed by GitHub
parent fabdddca21
commit a2f245e2fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 270 additions and 70 deletions

View File

@ -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"]
}

View File

@ -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",

View File

@ -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",

View File

@ -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">

View File

@ -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} />

View File

@ -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("");
}
};

View File

@ -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);

View File

@ -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>,
);

View 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;

View 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;

View 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
View 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;