Browse Source

fix(frontend): Refactor frontend config (#4261)

sp.wack 1 year ago
parent
commit
9d6c1e569d

+ 1 - 4
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
 # GitHub OAuth
 VITE_GITHUB_CLIENT_ID=""
 VITE_GITHUB_CLIENT_ID=""

+ 12 - 20
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 userEvent from "@testing-library/user-event";
 import { renderWithProviders } from "test-utils";
 import { renderWithProviders } from "test-utils";
 import { describe, it, expect, vi, Mock, afterEach } from "vitest";
 import { describe, it, expect, vi, Mock, afterEach } from "vitest";
-import { uploadFiles, listFiles } from "#/services/fileService";
 import toast from "#/utils/toast";
 import toast from "#/utils/toast";
 import AgentState from "#/types/AgentState";
 import AgentState from "#/types/AgentState";
 import FileExplorer from "#/components/file-explorer/FileExplorer";
 import FileExplorer from "#/components/file-explorer/FileExplorer";
+import OpenHands from "#/api/open-hands";
 
 
 const toastSpy = vi.spyOn(toast, "error");
 const toastSpy = vi.spyOn(toast, "error");
+const uploadFilesSpy = vi.spyOn(OpenHands, "uploadFiles");
+const getFilesSpy = vi.spyOn(OpenHands, "getFiles");
 
 
 vi.mock("../../services/fileService", async () => ({
 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(),
   uploadFiles: vi.fn(),
 }));
 }));
 
 
@@ -42,7 +34,7 @@ describe.skip("FileExplorer", () => {
 
 
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("file1.ts")).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");
   it.todo("should render an empty workspace");
@@ -53,12 +45,12 @@ describe.skip("FileExplorer", () => {
 
 
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("file1.ts")).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");
     const refreshButton = screen.getByTestId("refresh");
     await user.click(refreshButton);
     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 () => {
   it("should toggle the explorer visibility when clicking the toggle button", async () => {
@@ -84,15 +76,15 @@ describe.skip("FileExplorer", () => {
     await user.upload(uploadFileInput, file);
     await user.upload(uploadFileInput, file);
 
 
     // TODO: Improve this test by passing expected argument to `uploadFiles`
     // 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 file2 = new File([""], "file-name-2");
     const uploadDirInput = await screen.findByTestId("file-input");
     const uploadDirInput = await screen.findByTestId("file-input");
     await user.upload(uploadDirInput, [file, file2]);
     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", () => {
   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.todo("should download a file");
 
 
   it("should display an error toast if file upload fails", async () => {
   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();
     const user = userEvent.setup();
     renderFileExplorerWithRunningAgentState();
     renderFileExplorerWithRunningAgentState();
 
 
@@ -113,7 +105,7 @@ describe.skip("FileExplorer", () => {
 
 
     await user.upload(uploadFileInput, file);
     await user.upload(uploadFileInput, file);
 
 
-    expect(uploadFiles).rejects.toThrow();
+    expect(uploadFilesSpy).rejects.toThrow();
     expect(toastSpy).toHaveBeenCalledWith(
     expect(toastSpy).toHaveBeenCalledWith(
       expect.stringContaining("upload-error"),
       expect.stringContaining("upload-error"),
       expect.any(String),
       expect.any(String),

+ 11 - 18
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 userEvent from "@testing-library/user-event";
 import { renderWithProviders } from "test-utils";
 import { renderWithProviders } from "test-utils";
 import { vi, describe, afterEach, it, expect } from "vitest";
 import { vi, describe, afterEach, it, expect } from "vitest";
-import { selectFile, listFiles } from "#/services/fileService";
 import TreeNode from "#/components/file-explorer/TreeNode";
 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 () => ({
 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(),
   uploadFile: vi.fn(),
 }));
 }));
 
 
@@ -31,7 +24,7 @@ describe.skip("TreeNode", () => {
 
 
   it("should render a folder if it's in a subdir", async () => {
   it("should render a folder if it's in a subdir", async () => {
     renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
     renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
-    expect(listFiles).toHaveBeenCalledWith("/folder1/");
+    expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
 
 
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("folder1")).toBeInTheDocument();
     expect(await screen.findByText("file2.ts")).toBeInTheDocument();
     expect(await screen.findByText("file2.ts")).toBeInTheDocument();
@@ -63,27 +56,27 @@ describe.skip("TreeNode", () => {
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
 
 
     await user.click(folder1);
     await user.click(folder1);
-    expect(listFiles).toHaveBeenCalledWith("/folder1/");
+    expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
 
 
     expect(folder1).toBeInTheDocument();
     expect(folder1).toBeInTheDocument();
     expect(await screen.findByText("file2.ts")).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();
     const user = userEvent.setup();
     renderWithProviders(<TreeNode path="/folder1/file2.ts" defaultOpen />);
     renderWithProviders(<TreeNode path="/folder1/file2.ts" defaultOpen />);
 
 
     const file2 = screen.getByText("file2.ts");
     const file2 = screen.getByText("file2.ts");
     await user.click(file2);
     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 () => {
   it("should render the full explorer given the defaultOpen prop", async () => {
     const user = userEvent.setup();
     const user = userEvent.setup();
     renderWithProviders(<TreeNode path="/" defaultOpen />);
     renderWithProviders(<TreeNode path="/" defaultOpen />);
 
 
-    expect(listFiles).toHaveBeenCalledWith("/");
+    expect(getFilesSpy).toHaveBeenCalledWith("/");
 
 
     const file1 = await screen.findByText("file1.ts");
     const file1 = await screen.findByText("file1.ts");
     const folder1 = await screen.findByText("folder1");
     const folder1 = await screen.findByText("folder1");
@@ -93,7 +86,7 @@ describe.skip("TreeNode", () => {
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
 
 
     await user.click(folder1);
     await user.click(folder1);
-    expect(listFiles).toHaveBeenCalledWith("folder1/");
+    expect(getFilesSpy).toHaveBeenCalledWith("folder1/");
 
 
     expect(file1).toBeInTheDocument();
     expect(file1).toBeInTheDocument();
     expect(folder1).toBeInTheDocument();
     expect(folder1).toBeInTheDocument();
@@ -109,7 +102,7 @@ describe.skip("TreeNode", () => {
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
     expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
 
 
     await userEvent.click(folder1);
     await userEvent.click(folder1);
-    expect(listFiles).toHaveBeenCalledWith("/folder1/");
+    expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
 
 
     expect(folder1).toBeInTheDocument();
     expect(folder1).toBeInTheDocument();
     expect(await screen.findByText("file2.ts")).toBeInTheDocument();
     expect(await screen.findByText("file2.ts")).toBeInTheDocument();

+ 44 - 39
frontend/src/api/open-hands.ts

@@ -1,36 +1,20 @@
-interface ErrorResponse {
-  error: string;
-}
-
-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;
-}
+import {
+  SaveFileSuccessResponse,
+  FileUploadSuccessResponse,
+  Feedback,
+  FeedbackResponse,
+  GitHubAccessTokenResponse,
+  ErrorResponse,
+} from "./open-hands.types";
 
 
-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
  * Class to interact with the OpenHands API
@@ -39,7 +23,7 @@ class OpenHands {
   /**
   /**
    * Base URL of the OpenHands API
    * Base URL of the OpenHands API
    */
    */
-  static BASE_URL = "http://localhost:3000";
+  static BASE_URL = generateBaseURL();
 
 
   /**
   /**
    * Retrieve the list of models available
    * Retrieve the list of models available
@@ -73,12 +57,17 @@ class OpenHands {
   /**
   /**
    * Retrieve the list of files available in the workspace
    * Retrieve the list of files available in the workspace
    * @param token User token provided by the server
    * @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<string[]> {
-    const response = await fetch(`${OpenHands.BASE_URL}/api/list-files`, {
+  static async getFiles(token: string, path?: string): Promise<string[]> {
+    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),
       headers: OpenHands.generateHeaders(token),
     });
     });
+
     return response.json();
     return response.json();
   }
   }
 
 
@@ -126,12 +115,12 @@ class OpenHands {
    * @param file File to upload
    * @param file File to upload
    * @returns Success message or error message
    * @returns Success message or error message
    */
    */
-  static async uploadFile(
+  static async uploadFiles(
     token: string,
     token: string,
-    file: File,
+    file: File[],
   ): Promise<FileUploadSuccessResponse | ErrorResponse> {
   ): Promise<FileUploadSuccessResponse | ErrorResponse> {
     const formData = new FormData();
     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`, {
     const response = await fetch(`${OpenHands.BASE_URL}/api/upload-files`, {
       method: "POST",
       method: "POST",
@@ -174,6 +163,22 @@ class OpenHands {
     return response.json();
     return response.json();
   }
   }
 
 
+  /**
+   * Get the GitHub access token
+   * @param code Code provided by GitHub
+   * @returns GitHub access token
+   */
+  static async getGitHubAccessToken(
+    code: string,
+  ): Promise<GitHubAccessTokenResponse> {
+    const response = await fetch(`${OpenHands.BASE_URL}/github/callback`, {
+      method: "POST",
+      body: JSON.stringify({ code }),
+    });
+
+    return response.json();
+  }
+
   /**
   /**
    * Generate the headers for the request
    * Generate the headers for the request
    * @param token User token provided by the server
    * @param token User token provided by the server

+ 37 - 0
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[];
+}

+ 6 - 0
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;

+ 35 - 32
frontend/src/components/file-explorer/FileExplorer.tsx

@@ -12,7 +12,6 @@ import { useTranslation } from "react-i18next";
 import { twMerge } from "tailwind-merge";
 import { twMerge } from "tailwind-merge";
 import AgentState from "#/types/AgentState";
 import AgentState from "#/types/AgentState";
 import { setRefreshID } from "#/state/codeSlice";
 import { setRefreshID } from "#/state/codeSlice";
-import { uploadFiles } from "#/services/fileService";
 import IconButton from "../IconButton";
 import IconButton from "../IconButton";
 import ExplorerTree from "./ExplorerTree";
 import ExplorerTree from "./ExplorerTree";
 import toast from "#/utils/toast";
 import toast from "#/utils/toast";
@@ -20,6 +19,7 @@ import { RootState } from "#/store";
 import { I18nKey } from "#/i18n/declaration";
 import { I18nKey } from "#/i18n/declaration";
 import OpenHands from "#/api/open-hands";
 import OpenHands from "#/api/open-hands";
 import { useFiles } from "#/context/files";
 import { useFiles } from "#/context/files";
+import { isOpenHandsErrorResponse } from "#/api/open-hands.utils";
 
 
 interface ExplorerActionsProps {
 interface ExplorerActionsProps {
   onRefresh: () => void;
   onRefresh: () => void;
@@ -118,43 +118,46 @@ function FileExplorer() {
     revalidate();
     revalidate();
   };
   };
 
 
-  const uploadFileData = async (toAdd: FileList) => {
+  const uploadFileData = async (files: FileList) => {
     try {
     try {
-      const result = await uploadFiles(toAdd);
-
-      if (result.error) {
-        // Handle error response
-        toast.error(
-          `upload-error-${new Date().getTime()}`,
-          result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
-        );
-        return;
-      }
+      const token = localStorage.getItem("token");
+      if (token) {
+        const result = await OpenHands.uploadFiles(token, Array.from(files));
 
 
-      const uploadedCount = result.uploadedFiles.length;
-      const skippedCount = result.skippedFiles.length;
+        if (isOpenHandsErrorResponse(result)) {
+          // Handle error response
+          toast.error(
+            `upload-error-${new Date().getTime()}`,
+            result.error || t(I18nKey.EXPLORER$UPLOAD_ERROR_MESSAGE),
+          );
+          return;
+        }
 
 
-      if (uploadedCount > 0) {
-        toast.success(
-          `upload-success-${new Date().getTime()}`,
-          t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
-            count: uploadedCount,
-          }),
-        );
-      }
+        const uploadedCount = result.uploaded_files.length;
+        const skippedCount = result.skipped_files.length;
 
 
-      if (skippedCount > 0) {
-        const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
-          count: skippedCount,
-        });
-        toast.info(message);
-      }
+        if (uploadedCount > 0) {
+          toast.success(
+            `upload-success-${new Date().getTime()}`,
+            t(I18nKey.EXPLORER$UPLOAD_SUCCESS_MESSAGE, {
+              count: uploadedCount,
+            }),
+          );
+        }
 
 
-      if (uploadedCount === 0 && skippedCount === 0) {
-        toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
-      }
+        if (skippedCount > 0) {
+          const message = t(I18nKey.EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE, {
+            count: skippedCount,
+          });
+          toast.info(message);
+        }
 
 
-      refreshWorkspace();
+        if (uploadedCount === 0 && skippedCount === 0) {
+          toast.info(t(I18nKey.EXPLORER$NO_FILES_UPLOADED_MESSAGE));
+        }
+
+        refreshWorkspace();
+      }
     } catch (error) {
     } catch (error) {
       // Handle unexpected errors (network issues, etc.)
       // Handle unexpected errors (network issues, etc.)
       toast.error(
       toast.error(

+ 6 - 2
frontend/src/components/file-explorer/TreeNode.tsx

@@ -3,7 +3,6 @@ import { useSelector } from "react-redux";
 import { RootState } from "#/store";
 import { RootState } from "#/store";
 import FolderIcon from "../FolderIcon";
 import FolderIcon from "../FolderIcon";
 import FileIcon from "../FileIcons";
 import FileIcon from "../FileIcons";
-import { listFiles } from "#/services/fileService";
 import OpenHands from "#/api/open-hands";
 import OpenHands from "#/api/open-hands";
 import { useFiles } from "#/context/files";
 import { useFiles } from "#/context/files";
 import { cn } from "#/utils/utils";
 import { cn } from "#/utils/utils";
@@ -58,7 +57,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
       setChildren(null);
       setChildren(null);
       return;
       return;
     }
     }
-    setChildren(await listFiles(path));
+
+    const token = localStorage.getItem("token");
+    if (token) {
+      const newChildren = await OpenHands.getFiles(token, path);
+      setChildren(newChildren);
+    }
   };
   };
 
 
   React.useEffect(() => {
   React.useEffect(() => {

+ 2 - 1
frontend/src/components/modals/feedback/FeedbackModal.tsx

@@ -8,7 +8,8 @@ import toast from "#/utils/toast";
 import { getToken } from "#/services/auth";
 import { getToken } from "#/services/auth";
 import { removeApiKey, removeUnwantedKeys } from "#/utils/utils";
 import { removeApiKey, removeUnwantedKeys } from "#/utils/utils";
 import { useSocket } from "#/context/socket";
 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) => {
 const isEmailValid = (email: string) => {
   // Regular expression to validate email format
   // Regular expression to validate email format

+ 2 - 8
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(
     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) => {
     ws.addEventListener("open", (event) => {

+ 9 - 7
frontend/src/routes/_index/route.tsx

@@ -1,5 +1,6 @@
 import {
 import {
   ClientActionFunctionArgs,
   ClientActionFunctionArgs,
+  ClientLoaderFunctionArgs,
   json,
   json,
   redirect,
   redirect,
   useLoaderData,
   useLoaderData,
@@ -26,10 +27,6 @@ import { removeFile, setInitialQuery } from "#/state/initial-query-slice";
 import { clientLoader as rootClientLoader } from "#/root";
 import { clientLoader as rootClientLoader } from "#/root";
 import { UploadedFilePreview } from "./uploaded-file-preview";
 import { UploadedFilePreview } from "./uploaded-file-preview";
 
 
-const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
-const redirectUri = "http://localhost:3001/oauth/github/callback";
-const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`;
-
 interface AttachedFilesSliderProps {
 interface AttachedFilesSliderProps {
   files: string[];
   files: string[];
   onRemove: (file: string) => void;
   onRemove: (file: string) => void;
@@ -74,7 +71,7 @@ function GitHubAuth({
   );
   );
 }
 }
 
 
-export const clientLoader = async () => {
+export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
   const ghToken = localStorage.getItem("ghToken");
   const ghToken = localStorage.getItem("ghToken");
   let repositories: GitHubRepository[] = [];
   let repositories: GitHubRepository[] = [];
   if (ghToken) {
   if (ghToken) {
@@ -84,7 +81,12 @@ export const clientLoader = async () => {
     }
     }
   }
   }
 
 
-  return json({ repositories });
+  const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID;
+  const requestUrl = new URL(request.url);
+  const redirectUri = `${requestUrl.origin}/oauth/github/callback`;
+  const githubAuthUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=repo,user`;
+
+  return json({ repositories, githubAuthUrl });
 };
 };
 
 
 export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
 export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
@@ -98,7 +100,7 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
 function Home() {
 function Home() {
   const rootData = useRouteLoaderData<typeof rootClientLoader>("root");
   const rootData = useRouteLoaderData<typeof rootClientLoader>("root");
   const navigation = useNavigation();
   const navigation = useNavigation();
-  const { repositories } = useLoaderData<typeof clientLoader>();
+  const { repositories, githubAuthUrl } = useLoaderData<typeof clientLoader>();
   const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
   const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
     React.useState(false);
     React.useState(false);
   const [importedFile, setImportedFile] = React.useState<File | null>(null);
   const [importedFile, setImportedFile] = React.useState<File | null>(null);

+ 1 - 1
frontend/src/routes/app.tsx

@@ -62,7 +62,7 @@ export const clientLoader = async () => {
     const file = new File([blob], "imported-project.zip", {
     const file = new File([blob], "imported-project.zip", {
       type: blob.type,
       type: blob.type,
     });
     });
-    await OpenHands.uploadFile(token, file);
+    await OpenHands.uploadFiles(token, [file]);
   }
   }
 
 
   if (repo) localStorage.setItem("repo", repo);
   if (repo) localStorage.setItem("repo", repo);

+ 3 - 19
frontend/src/routes/oauth.github.callback.tsx

@@ -4,24 +4,7 @@ import {
   redirect,
   redirect,
   useLoaderData,
   useLoaderData,
 } from "@remix-run/react";
 } from "@remix-run/react";
-
-const retrieveGitHubAccessToken = async (
-  code: string,
-): Promise<{ access_token: string }> => {
-  const response = await fetch("http://localhost:3000/github/callback", {
-    method: "POST",
-    headers: {
-      "Content-Type": "application/json",
-    },
-    body: JSON.stringify({ code }),
-  });
-
-  if (!response.ok) {
-    throw new Error("Failed to retrieve access token");
-  }
-
-  return response.json();
-};
+import OpenHands from "#/api/open-hands";
 
 
 export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
 export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
   const url = new URL(request.url);
   const url = new URL(request.url);
@@ -29,7 +12,8 @@ export const clientLoader = async ({ request }: ClientLoaderFunctionArgs) => {
 
 
   if (code) {
   if (code) {
     // request to the server to exchange the code for a token
     // request to the server to exchange the code for a token
-    const { access_token: accessToken } = await retrieveGitHubAccessToken(code);
+    const { access_token: accessToken } =
+      await OpenHands.getGitHubAccessToken(code);
     // set the token in local storage
     // set the token in local storage
     localStorage.setItem("ghToken", accessToken);
     localStorage.setItem("ghToken", accessToken);
     return redirect("/");
     return redirect("/");

+ 0 - 75
frontend/src/services/fileService.ts

@@ -1,75 +0,0 @@
-import { request } from "./api";
-
-export async function selectFile(file: string): Promise<string> {
-  const encodedFile = encodeURIComponent(file);
-  const data = await request(`/api/select-file?file=${encodedFile}`);
-  return data.code as string;
-}
-
-interface UploadResult {
-  message: string;
-  uploadedFiles: string[];
-  skippedFiles: Array<{ name: string; reason: string }>;
-  error?: string;
-}
-
-export async function uploadFiles(files: FileList): Promise<UploadResult> {
-  const formData = new FormData();
-  const skippedFiles: Array<{ name: string; reason: string }> = [];
-
-  let uploadedCount = 0;
-
-  for (let i = 0; i < files.length; i += 1) {
-    const file = files[i];
-
-    if (
-      file.name.includes("..") ||
-      file.name.includes("/") ||
-      file.name.includes("\\")
-    ) {
-      skippedFiles.push({
-        name: file.name,
-        reason: "Invalid file name",
-      });
-    } else {
-      formData.append("files", file);
-      uploadedCount += 1;
-    }
-  }
-
-  formData.append("skippedFilesCount", skippedFiles.length.toString());
-  formData.append("uploadedFilesCount", uploadedCount.toString());
-
-  const response = await request("http://localhost:3000/api/upload-files", {
-    method: "POST",
-    body: formData,
-  });
-
-  if (
-    typeof response.message !== "string" ||
-    !Array.isArray(response.uploaded_files) ||
-    !Array.isArray(response.skipped_files)
-  ) {
-    throw new Error("Unexpected response structure from server");
-  }
-
-  return {
-    message: response.message,
-    uploadedFiles: response.uploaded_files,
-    skippedFiles: [...skippedFiles, ...response.skipped_files],
-  };
-}
-
-export async function listFiles(
-  path: string | undefined = undefined,
-): Promise<string[]> {
-  let url = "http://localhost:3000/api/list-files";
-  if (path) {
-    url = `http://localhost:3000/api/list-files?path=${encodeURIComponent(path)}`;
-  }
-  const data = await request(url);
-  if (!Array.isArray(data)) {
-    throw new Error("Invalid response format: data is not an array");
-  }
-  return data;
-}

+ 28 - 73
frontend/vite.config.ts

@@ -1,82 +1,37 @@
 /* eslint-disable import/no-extraneous-dependencies */
 /* eslint-disable import/no-extraneous-dependencies */
 /// <reference types="vitest" />
 /// <reference types="vitest" />
 /// <reference types="vite-plugin-svgr/client" />
 /// <reference types="vite-plugin-svgr/client" />
-import { defineConfig, loadEnv } from "vite";
+import { defineConfig } from "vite";
 import viteTsconfigPaths from "vite-tsconfig-paths";
 import viteTsconfigPaths from "vite-tsconfig-paths";
 import svgr from "vite-plugin-svgr";
 import svgr from "vite-plugin-svgr";
 import { vitePlugin as remix } from "@remix-run/dev";
 import { vitePlugin as remix } from "@remix-run/dev";
 
 
-export default defineConfig(({ mode }) => {
-  const {
-    VITE_BACKEND_HOST = "127.0.0.1:3000",
-    VITE_USE_TLS = "false",
-    VITE_FRONTEND_PORT = "3001",
-    VITE_INSECURE_SKIP_VERIFY = "false",
-    VITE_WATCH_USE_POLLING = "false",
-  } = loadEnv(mode, process.cwd());
-
-  const USE_TLS = VITE_USE_TLS === "true";
-  const INSECURE_SKIP_VERIFY = VITE_INSECURE_SKIP_VERIFY === "true";
-  const PROTOCOL = USE_TLS ? "https" : "http";
-  const WS_PROTOCOL = USE_TLS ? "wss" : "ws";
-
-  const API_URL = `${PROTOCOL}://${VITE_BACKEND_HOST}/`;
-  const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`;
-  const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10);
-
-  // check BACKEND_HOST is something like "example.com"
-  if (!VITE_BACKEND_HOST.match(/^([\w\d-]+(\.[\w\d-]+)+(:\d+)?)/)) {
-    throw new Error(
-      `Invalid BACKEND_HOST ${VITE_BACKEND_HOST}, example BACKEND_HOST 127.0.0.1:3000`,
-    );
-  }
-
-  return {
-    plugins: [
-      !process.env.VITEST &&
-        remix({
-          future: {
-            v3_fetcherPersist: true,
-            v3_relativeSplatPath: true,
-            v3_throwAbortReason: true,
-          },
-          appDirectory: "src",
-          ssr: false,
-        }),
-      viteTsconfigPaths(),
-      svgr(),
-    ],
-    ssr: {
-      noExternal: ["react-syntax-highlighter"],
-    },
-    clearScreen: false,
-    server: {
-      watch: {
-        usePolling: VITE_WATCH_USE_POLLING === "true",
-      },
-      port: FE_PORT,
-      proxy: {
-        "/api": {
-          target: API_URL,
-          changeOrigin: true,
-          secure: !INSECURE_SKIP_VERIFY,
-        },
-        "/ws": {
-          target: WS_URL,
-          ws: true,
-          changeOrigin: true,
-          secure: !INSECURE_SKIP_VERIFY,
+export default defineConfig(() => ({
+  plugins: [
+    !process.env.VITEST &&
+      remix({
+        future: {
+          v3_fetcherPersist: true,
+          v3_relativeSplatPath: true,
+          v3_throwAbortReason: true,
         },
         },
-      },
-    },
-    test: {
-      environment: "jsdom",
-      setupFiles: ["vitest.setup.ts"],
-      coverage: {
-        reporter: ["text", "json", "html", "lcov", "text-summary"],
-        reportsDirectory: "coverage",
-        include: ["src/**/*.{ts,tsx}"],
-      },
+        appDirectory: "src",
+        ssr: false,
+      }),
+    viteTsconfigPaths(),
+    svgr(),
+  ],
+  ssr: {
+    noExternal: ["react-syntax-highlighter"],
+  },
+  clearScreen: false,
+  test: {
+    environment: "jsdom",
+    setupFiles: ["vitest.setup.ts"],
+    coverage: {
+      reporter: ["text", "json", "html", "lcov", "text-summary"],
+      reportsDirectory: "coverage",
+      include: ["src/**/*.{ts,tsx}"],
     },
     },
-  };
-});
+  },
+}));