diff --git a/Makefile b/Makefile index d87ea72296..6c89b04586 100644 --- a/Makefile +++ b/Makefile @@ -195,7 +195,7 @@ start-backend: # Start frontend start-frontend: @echo "$(YELLOW)Starting frontend...$(RESET)" - @cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start + @cd frontend && VITE_BACKEND_HOST=$(BACKEND_HOST_PORT) VITE_FRONTEND_PORT=$(FRONTEND_PORT) npm run start -- --port $(FRONTEND_PORT) # Common setup for running the app (non-callable) _run_setup: diff --git a/frontend/.env.sample b/frontend/.env.sample index 217d8c526a..5178426762 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -1,7 +1,4 @@ -VITE_BACKEND_HOST="127.0.0.1:3000" -VITE_USE_TLS="false" -VITE_INSECURE_SKIP_VERIFY="false" -VITE_FRONTEND_PORT="3001" +VITE_BACKEND_BASE_URL="localhost:3000" # Backend URL without protocol (e.g. localhost:3000) # GitHub OAuth VITE_GITHUB_CLIENT_ID="" diff --git a/frontend/README.md b/frontend/README.md index 5a9a5b9771..7d05e8a9ba 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,63 +1,123 @@ # Getting Started with the OpenHands Frontend -The frontend code can be run against the docker image defined in the [Main README](../README.md) as a backend +## Overview -## Prerequisites +This is the frontend of the OpenHands project. It is a React application that provides a web interface for the OpenHands project. -A recent version of NodeJS / NPM (`brew install node`) +## Tech Stack -## Available Scripts +- Remix SPA Mode (React + Vite + React Router) +- TypeScript +- Redux +- Tailwind CSS +- i18next +- React Testing Library +- Vitest +- Mock Service Worker -In the project directory, you can run: +## Getting Started -### `npm run start -- --port 3001` +### Prerequisites -Runs the app in development mode.\ -Open [http://localhost:3001](http://localhost:3001) to view it in the browser. +- Node.js 20.x or later +- `npm`, `bun`, or any other package manager that supports the `package.json` file -The page will reload if you make edits.\ -You will also see any lint errors in the console. +### Installation -### `npm run make-i18n` +```sh +# Clone the repository +git clone https://github.com/All-Hands-AI/OpenHands.git -Generates the i18n declaration file.\ -Run this when first setting up the repository or when updating translations. +# Change the directory to the frontend +cd OpenHands/frontend -### `npm run test` - -Runs the available test suites for the application.\ -It launches the test runner in interactive watch mode, allowing you to see the results of your tests in real time. - -In order to skip all but one specific test file, like the one for the ChatInterface, the following command might be used: `npm run test -- -t "ChatInterface"` - -### `npm run build` - -Builds the app for production to the `dist` folder.\ -It correctly bundles React in production mode and optimizes the build for the best performance. - -The build is minified and the filenames include the hashes.\ -Your app is ready to be deployed! - -## Environment Variables - -You can set the environment variables in `frontend/.env` to configure the frontend. -The following variables are available: - -```javascript -VITE_BACKEND_HOST="127.0.0.1:3000" // The host of the backend -VITE_USE_TLS="false" // Whether to use TLS for the backend (includes HTTPS and WSS) -VITE_INSECURE_SKIP_VERIFY="false" // Whether to skip verifying the backend's certificate. Only takes effect if `VITE_USE_TLS` is true. Don't use this in production! -VITE_FRONTEND_PORT="3001" // The port of the frontend +# Install the dependencies +npm install ``` -You can also set the environment variables from outside the project, like `export VITE_BACKEND_HOST="127.0.0.1:3000"`. +### Running the Application in Development Mode -The outside environment variables will override the ones in the `.env` file. +We use `msw` to mock the backend API. To start the application with the mocked backend, run the following command: -## Learn More +```sh +npm run dev +``` -You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). +This will start the application in development mode. Open [http://localhost:3001](http://localhost:3001) to view it in the browser. -To learn React, check out the [React documentation](https://reactjs.org/). +**NOTE: The backend is _partially_ mocked using `msw`. Therefore, some features may not work as they would with the actual backend.** -For more information on tests, you can refer to the official documentation of [Vitest](https://vitest.dev/) and [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/). +### Running the Application with the Actual Backend (Production Mode) + +There are two ways to run the application with the actual backend: + +```sh +# Build the application from the root directory +make build + +# Start the application +make start +``` + +OR + +```sh +# Start the backend from the root directory +make start-backend + +# Build the frontend +cd frontend && npm run build + +# Serve the frontend +npm start -- --port 3001 +``` + +### Environment Variables + +TODO + +### Project Structure + +```sh +frontend +├── __tests__ # Tests +├── public +├── src +│ ├── api # API calls +│ ├── assets +│ ├── components # Reusable components +│ ├── context # Local state management +│ ├── hooks # Custom hooks +│ ├── i18n # Internationalization +│ ├── mocks # MSW mocks for development +│ ├── routes # React Router file-based routes +│ ├── services +│ ├── state # Redux state management +│ ├── types +│ ├── utils # Utility/helper functions +│ └── root.tsx # Entry point +└── .env.sample # Sample environment variables +``` + +### Features + +- Real-time updates with WebSockets +- Internationalization +- Router data loading with Remix +- User authentication with GitHub OAuth (if saas mode is enabled) + +## Testing + +We use `Vitest` for testing. To run the tests, run the following command: + +```sh +npm run test +``` + +## Contributing + +Please read the [CONTRIBUTING.md](../CONTRIBUTING.md) file for details on our code of conduct, and the process for submitting pull requests to us. + +## Troubleshooting + +TODO diff --git a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx index 2d9a5f166b..fcdd05a423 100644 --- a/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx +++ b/frontend/__tests__/components/file-explorer/FileExplorer.test.tsx @@ -2,24 +2,16 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { describe, it, expect, vi, Mock, afterEach } from "vitest"; -import { uploadFiles, listFiles } from "#/services/fileService"; import toast from "#/utils/toast"; import AgentState from "#/types/AgentState"; import FileExplorer from "#/components/file-explorer/FileExplorer"; +import OpenHands from "#/api/open-hands"; const toastSpy = vi.spyOn(toast, "error"); +const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles"); +const getFilesSpy = vi.spyOn(OpenHands, "getFiles"); vi.mock("../../services/fileService", async () => ({ - listFiles: vi.fn(async (path: string = "/") => { - if (path === "/") { - return Promise.resolve(["folder1/", "file1.ts"]); - } - if (path === "/folder1/" || path === "folder1/") { - return Promise.resolve(["file2.ts"]); - } - return Promise.resolve([]); - }), - uploadFiles: vi.fn(), })); @@ -42,7 +34,7 @@ describe.skip("FileExplorer", () => { expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file1.ts")).toBeInTheDocument(); - expect(listFiles).toHaveBeenCalledTimes(1); // once for root + expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root }); it.todo("should render an empty workspace"); @@ -53,12 +45,12 @@ describe.skip("FileExplorer", () => { expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file1.ts")).toBeInTheDocument(); - expect(listFiles).toHaveBeenCalledTimes(1); // once for root + expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root const refreshButton = screen.getByTestId("refresh"); await user.click(refreshButton); - expect(listFiles).toHaveBeenCalledTimes(2); // once for root, once for refresh button + expect(getFilesSpy).toHaveBeenCalledTimes(2); // once for root, once for refresh button }); it("should toggle the explorer visibility when clicking the toggle button", async () => { @@ -84,15 +76,15 @@ describe.skip("FileExplorer", () => { await user.upload(uploadFileInput, file); // TODO: Improve this test by passing expected argument to `uploadFiles` - expect(uploadFiles).toHaveBeenCalledOnce(); - expect(listFiles).toHaveBeenCalled(); + expect(uploadFilesSpy).toHaveBeenCalledOnce(); + expect(getFilesSpy).toHaveBeenCalled(); const file2 = new File([""], "file-name-2"); const uploadDirInput = await screen.findByTestId("file-input"); await user.upload(uploadDirInput, [file, file2]); - expect(uploadFiles).toHaveBeenCalledTimes(2); - expect(listFiles).toHaveBeenCalled(); + expect(uploadFilesSpy).toHaveBeenCalledTimes(2); + expect(getFilesSpy).toHaveBeenCalled(); }); it.todo("should upload files when dragging them to the explorer", () => { @@ -104,7 +96,7 @@ describe.skip("FileExplorer", () => { it.todo("should download a file"); it("should display an error toast if file upload fails", async () => { - (uploadFiles as Mock).mockRejectedValue(new Error()); + (uploadFilesSpy as Mock).mockRejectedValue(new Error()); const user = userEvent.setup(); renderFileExplorerWithRunningAgentState(); @@ -113,7 +105,7 @@ describe.skip("FileExplorer", () => { await user.upload(uploadFileInput, file); - expect(uploadFiles).rejects.toThrow(); + expect(uploadFilesSpy).rejects.toThrow(); expect(toastSpy).toHaveBeenCalledWith( expect.stringContaining("upload-error"), expect.any(String), diff --git a/frontend/__tests__/components/file-explorer/TreeNode.test.tsx b/frontend/__tests__/components/file-explorer/TreeNode.test.tsx index 2c465fbf80..42919b3660 100644 --- a/frontend/__tests__/components/file-explorer/TreeNode.test.tsx +++ b/frontend/__tests__/components/file-explorer/TreeNode.test.tsx @@ -2,20 +2,13 @@ import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "test-utils"; import { vi, describe, afterEach, it, expect } from "vitest"; -import { selectFile, listFiles } from "#/services/fileService"; import TreeNode from "#/components/file-explorer/TreeNode"; +import OpenHands from "#/api/open-hands"; + +const getFileSpy = vi.spyOn(OpenHands, "getFile"); +const getFilesSpy = vi.spyOn(OpenHands, "getFiles"); vi.mock("../../services/fileService", async () => ({ - listFiles: vi.fn(async (path: string = "/") => { - if (path === "/") { - return Promise.resolve(["folder1/", "file1.ts"]); - } - if (path === "/folder1/" || path === "folder1/") { - return Promise.resolve(["file2.ts"]); - } - return Promise.resolve([]); - }), - selectFile: vi.fn(async () => Promise.resolve({ code: "Hello world!" })), uploadFile: vi.fn(), })); @@ -31,7 +24,7 @@ describe.skip("TreeNode", () => { it("should render a folder if it's in a subdir", async () => { renderWithProviders(); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(await screen.findByText("folder1")).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); @@ -63,27 +56,27 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await user.click(folder1); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(folder1).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); }); - it("should call `selectFile` and return the full path of a file when clicking on a file", async () => { + it("should call `OpenHands.getFile` and return the full path of a file when clicking on a file", async () => { const user = userEvent.setup(); renderWithProviders(); const file2 = screen.getByText("file2.ts"); await user.click(file2); - expect(selectFile).toHaveBeenCalledWith("/folder1/file2.ts"); + expect(getFileSpy).toHaveBeenCalledWith("/folder1/file2.ts"); }); it("should render the full explorer given the defaultOpen prop", async () => { const user = userEvent.setup(); renderWithProviders(); - expect(listFiles).toHaveBeenCalledWith("/"); + expect(getFilesSpy).toHaveBeenCalledWith("/"); const file1 = await screen.findByText("file1.ts"); const folder1 = await screen.findByText("folder1"); @@ -93,7 +86,7 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await user.click(folder1); - expect(listFiles).toHaveBeenCalledWith("folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("folder1/"); expect(file1).toBeInTheDocument(); expect(folder1).toBeInTheDocument(); @@ -109,7 +102,7 @@ describe.skip("TreeNode", () => { expect(screen.queryByText("file2.ts")).not.toBeInTheDocument(); await userEvent.click(folder1); - expect(listFiles).toHaveBeenCalledWith("/folder1/"); + expect(getFilesSpy).toHaveBeenCalledWith("/folder1/"); expect(folder1).toBeInTheDocument(); expect(await screen.findByText("file2.ts")).toBeInTheDocument(); diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts index 8788ec1d15..c881b70a38 100644 --- a/frontend/src/api/open-hands.ts +++ b/frontend/src/api/open-hands.ts @@ -1,36 +1,20 @@ -interface ErrorResponse { - error: string; -} +import { + SaveFileSuccessResponse, + FileUploadSuccessResponse, + Feedback, + FeedbackResponse, + GitHubAccessTokenResponse, + ErrorResponse, +} from "./open-hands.types"; -interface SaveFileSuccessResponse { - message: string; -} - -interface FileUploadSuccessResponse { - message: string; - uploaded_files: string[]; - skipped_files: { name: string; reason: string }[]; -} - -interface FeedbackBodyResponse { - message: string; - feedback_id: string; - password: string; -} - -interface FeedbackResponse { - statusCode: number; - body: FeedbackBodyResponse; -} - -export interface Feedback { - version: string; - email: string; - token: string; - feedback: "positive" | "negative"; - permissions: "public" | "private"; - trajectory: unknown[]; -} +/** + * Generate the base URL of the OpenHands API + * @returns Base URL of the OpenHands API + */ +const generateBaseURL = () => { + const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000"; + return `http://${baseUrl}`; +}; /** * Class to interact with the OpenHands API @@ -39,7 +23,7 @@ class OpenHands { /** * Base URL of the OpenHands API */ - static BASE_URL = "http://localhost:3000"; + static BASE_URL = generateBaseURL(); /** * Retrieve the list of models available @@ -73,12 +57,17 @@ class OpenHands { /** * Retrieve the list of files available in the workspace * @param token User token provided by the server - * @returns List of files available in the workspace + * @param path Path to list files from + * @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace */ - static async getFiles(token: string): Promise { - const response = await fetch(`${OpenHands.BASE_URL}/api/list-files`, { + static async getFiles(token: string, path?: string): Promise { + const url = new URL(`${OpenHands.BASE_URL}/api/list-files`); + if (path) url.searchParams.append("path", encodeURIComponent(path)); + + const response = await fetch(url.toString(), { headers: OpenHands.generateHeaders(token), }); + return response.json(); } @@ -126,12 +115,12 @@ class OpenHands { * @param file File to upload * @returns Success message or error message */ - static async uploadFile( + static async uploadFiles( token: string, - file: File, + file: File[], ): Promise { const formData = new FormData(); - formData.append("files", file); + file.forEach((f) => formData.append("files", f)); const response = await fetch(`${OpenHands.BASE_URL}/api/upload-files`, { method: "POST", @@ -174,6 +163,22 @@ class OpenHands { return response.json(); } + /** + * Get the GitHub access token + * @param code Code provided by GitHub + * @returns GitHub access token + */ + static async getGitHubAccessToken( + code: string, + ): Promise { + const response = await fetch(`${OpenHands.BASE_URL}/github/callback`, { + method: "POST", + body: JSON.stringify({ code }), + }); + + return response.json(); + } + /** * Generate the headers for the request * @param token User token provided by the server diff --git a/frontend/src/api/open-hands.types.ts b/frontend/src/api/open-hands.types.ts new file mode 100644 index 0000000000..63edc9b034 --- /dev/null +++ b/frontend/src/api/open-hands.types.ts @@ -0,0 +1,37 @@ +export interface ErrorResponse { + error: string; +} + +export interface SaveFileSuccessResponse { + message: string; +} + +export interface FileUploadSuccessResponse { + message: string; + uploaded_files: string[]; + skipped_files: { name: string; reason: string }[]; +} + +export interface FeedbackBodyResponse { + message: string; + feedback_id: string; + password: string; +} + +export interface FeedbackResponse { + statusCode: number; + body: FeedbackBodyResponse; +} + +export interface GitHubAccessTokenResponse { + access_token: string; +} + +export interface Feedback { + version: string; + email: string; + token: string; + feedback: "positive" | "negative"; + permissions: "public" | "private"; + trajectory: unknown[]; +} diff --git a/frontend/src/api/open-hands.utils.ts b/frontend/src/api/open-hands.utils.ts new file mode 100644 index 0000000000..c2460d0636 --- /dev/null +++ b/frontend/src/api/open-hands.utils.ts @@ -0,0 +1,6 @@ +import { ErrorResponse, FileUploadSuccessResponse } from "./open-hands.types"; + +export const isOpenHandsErrorResponse = ( + data: ErrorResponse | FileUploadSuccessResponse, +): data is ErrorResponse => + typeof data === "object" && data !== null && "error" in data; diff --git a/frontend/src/components/file-explorer/FileExplorer.tsx b/frontend/src/components/file-explorer/FileExplorer.tsx index 7172dadfd3..8362723b53 100644 --- a/frontend/src/components/file-explorer/FileExplorer.tsx +++ b/frontend/src/components/file-explorer/FileExplorer.tsx @@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next"; import { twMerge } from "tailwind-merge"; import AgentState from "#/types/AgentState"; import { setRefreshID } from "#/state/codeSlice"; -import { uploadFiles } from "#/services/fileService"; import IconButton from "../IconButton"; import ExplorerTree from "./ExplorerTree"; import toast from "#/utils/toast"; @@ -20,6 +19,7 @@ import { RootState } from "#/store"; import { I18nKey } from "#/i18n/declaration"; import OpenHands from "#/api/open-hands"; import { useFiles } from "#/context/files"; +import { isOpenHandsErrorResponse } from "#/api/open-hands.utils"; interface ExplorerActionsProps { onRefresh: () => void; @@ -118,43 +118,46 @@ function FileExplorer() { revalidate(); }; - const uploadFileData = async (toAdd: FileList) => { + const uploadFileData = async (files: FileList) => { try { - const result = await uploadFiles(toAdd); + const token = localStorage.getItem("token"); + if (token) { + const result = await OpenHands.uploadFiles(token, Array.from(files)); - if (result.error) { - // Handle error response - toast.error( - `upload-error-${new Date().getTime()}`, - result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), - ); - return; + if (isOpenHandsErrorResponse(result)) { + // Handle error response + toast.error( + `upload-error-${new Date().getTime()}`, + result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE), + ); + return; + } + + const uploadedCount = result.uploaded_files.length; + const skippedCount = result.skipped_files.length; + + if (uploadedCount > 0) { + toast.success( + `upload-success-${new Date().getTime()}`, + t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { + count: uploadedCount, + }), + ); + } + + if (skippedCount > 0) { + const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { + count: skippedCount, + }); + toast.info(message); + } + + if (uploadedCount === 0 && skippedCount === 0) { + toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); + } + + refreshWorkspace(); } - - const uploadedCount = result.uploadedFiles.length; - const skippedCount = result.skippedFiles.length; - - if (uploadedCount > 0) { - toast.success( - `upload-success-${new Date().getTime()}`, - t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, { - count: uploadedCount, - }), - ); - } - - if (skippedCount > 0) { - const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, { - count: skippedCount, - }); - toast.info(message); - } - - if (uploadedCount === 0 && skippedCount === 0) { - toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE)); - } - - refreshWorkspace(); } catch (error) { // Handle unexpected errors (network issues, etc.) toast.error( diff --git a/frontend/src/components/file-explorer/TreeNode.tsx b/frontend/src/components/file-explorer/TreeNode.tsx index fed4c4b624..fddcc0288d 100644 --- a/frontend/src/components/file-explorer/TreeNode.tsx +++ b/frontend/src/components/file-explorer/TreeNode.tsx @@ -3,7 +3,6 @@ import { useSelector } from "react-redux"; import { RootState } from "#/store"; import FolderIcon from "../FolderIcon"; import FileIcon from "../FileIcons"; -import { listFiles } from "#/services/fileService"; import OpenHands from "#/api/open-hands"; import { useFiles } from "#/context/files"; import { cn } from "#/utils/utils"; @@ -58,7 +57,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) { setChildren(null); return; } - setChildren(await listFiles(path)); + + const token = localStorage.getItem("token"); + if (token) { + const newChildren = await OpenHands.getFiles(token, path); + setChildren(newChildren); + } }; React.useEffect(() => { diff --git a/frontend/src/components/modals/feedback/FeedbackModal.tsx b/frontend/src/components/modals/feedback/FeedbackModal.tsx index 1149c87865..e7051da694 100644 --- a/frontend/src/components/modals/feedback/FeedbackModal.tsx +++ b/frontend/src/components/modals/feedback/FeedbackModal.tsx @@ -8,7 +8,8 @@ import toast from "#/utils/toast"; import { getToken } from "#/services/auth"; import { removeApiKey, removeUnwantedKeys } from "#/utils/utils"; import { useSocket } from "#/context/socket"; -import OpenHands, { Feedback } from "#/api/open-hands"; +import OpenHands from "#/api/open-hands"; +import { Feedback } from "#/api/open-hands.types"; const isEmailValid = (email: string) => { // Regular expression to validate email format diff --git a/frontend/src/context/socket.tsx b/frontend/src/context/socket.tsx index 523f1e601d..723a6478b9 100644 --- a/frontend/src/context/socket.tsx +++ b/frontend/src/context/socket.tsx @@ -45,15 +45,9 @@ function SocketProvider({ children }: SocketProviderProps) { ); } - /* - const wsUrl = new URL("/", document.baseURI); - wsUrl.protocol = wsUrl.protocol.replace("http", "ws"); - if (options?.token) wsUrl.searchParams.set("token", options.token); - const ws = new WebSocket(`${wsUrl.origin}/ws`); - */ - // TODO: Remove hardcoded URL; may have to use a proxy + const baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || "localhost:3000"; const ws = new WebSocket( - `ws://localhost:3000/ws${options?.token ? `?token=${options.token}` : ""}`, + `ws://${baseUrl}/ws${options?.token ? `?token=${options.token}` : ""}`, ); ws.addEventListener("open", (event) => { diff --git a/frontend/src/root.tsx b/frontend/src/root.tsx index a079c69b05..82dc29b54e 100644 --- a/frontend/src/root.tsx +++ b/frontend/src/root.tsx @@ -1,6 +1,7 @@ import { Links, Meta, + MetaFunction, Outlet, Scripts, ScrollRestoration, @@ -49,6 +50,11 @@ export function Layout({ children }: { children: React.ReactNode }) { ); } +export const meta: MetaFunction = () => [ + { title: "OpenHands" }, + { name: "description", content: "Let's Start Building!" }, +]; + export const clientLoader = async () => { let token = localStorage.getItem("token"); const ghToken = localStorage.getItem("ghToken"); @@ -157,7 +163,7 @@ export default function App() { }; return ( -
+