mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
Merge branch 'main' into tobitege/fn_calling
This commit is contained in:
commit
864894815f
8
.github/workflows/ghcr-build.yml
vendored
8
.github/workflows/ghcr-build.yml
vendored
@ -88,14 +88,6 @@ jobs:
|
||||
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
|
||||
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
|
||||
echo "Hash from app image: $hash_from_app_image"
|
||||
# This test should move when we have a test suite for the app image
|
||||
- name: Test docker in App Image
|
||||
run: |
|
||||
# Lowercase the repository owner
|
||||
export REPO_OWNER=${{ github.repository_owner }}
|
||||
REPO_OWNER=$(echo $REPO_OWNER | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${REPO_OWNER}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "docker run hello-world"
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
|
||||
@ -42,10 +42,10 @@ system requirements and more information.
|
||||
```bash
|
||||
export WORKSPACE_BASE=$(pwd)/workspace
|
||||
|
||||
docker pull ghcr.io/all-hands-ai/runtime:0.10-nikolaik
|
||||
docker pull ghcr.io/all-hands-ai/runtime:0.11-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.10-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.11-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
@ -53,7 +53,7 @@ docker run -it --pull=always \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
ghcr.io/all-hands-ai/openhands:0.10
|
||||
ghcr.io/all-hands-ai/openhands:0.11
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@ -46,14 +46,6 @@ RUN mkdir -p $WORKSPACE_BASE
|
||||
RUN apt-get update -y \
|
||||
&& apt-get install -y curl ssh sudo
|
||||
|
||||
# Install Docker - https://docs.docker.com/engine/install/debian/
|
||||
RUN apt-get install ca-certificates curl \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian bookworm stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null \
|
||||
&& apt-get update \
|
||||
&& apt install -y docker-ce
|
||||
|
||||
# Default is 1000, but OSX is often 501
|
||||
RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
|
||||
@ -57,7 +57,7 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
ghcr.io/all-hands-ai/openhands:0.10 \
|
||||
ghcr.io/all-hands-ai/openhands:0.11 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@ -38,19 +38,7 @@ This will produce a new image called `custom-image`, which will be available in
|
||||
> Note that in the configuration described in this document, OpenHands will run as user "openhands" inside the
|
||||
> sandbox and thus all packages installed via the docker file should be available to all users on the system, not just root.
|
||||
|
||||
## Option 1: Using the Docker Command
|
||||
|
||||
[In the docker command](https://docs.all-hands.dev/modules/usage/installation#start-the-app), replace
|
||||
`SANDBOX_RUNTIME_CONTAINER_IMAGE` with `SANDBOX_BASE_CONTAINER_IMAGE` and set it to the desired image.
|
||||
This can be an image you’ve already pulled or one you’ve built:
|
||||
|
||||
```bash
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_BASE_CONTAINER_IMAGE=custom-image \
|
||||
...
|
||||
```
|
||||
|
||||
## Option 2: Using the Development Workflow
|
||||
## Using the Development Workflow
|
||||
|
||||
### Setup
|
||||
|
||||
|
||||
@ -51,6 +51,6 @@ docker run -it \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
ghcr.io/all-hands-ai/openhands:0.10 \
|
||||
ghcr.io/all-hands-ai/openhands:0.11 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@ -14,10 +14,10 @@ existing code that you'd like to modify.
|
||||
```bash
|
||||
export WORKSPACE_BASE=$(pwd)/workspace
|
||||
|
||||
docker pull ghcr.io/all-hands-ai/runtime:0.10-nikolaik
|
||||
docker pull ghcr.io/all-hands-ai/runtime:0.11-nikolaik
|
||||
|
||||
docker run -it --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.10-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.11-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
@ -25,7 +25,7 @@ docker run -it --pull=always \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
ghcr.io/all-hands-ai/openhands:0.10
|
||||
ghcr.io/all-hands-ai/openhands:0.11
|
||||
```
|
||||
|
||||
You can also run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), as an [interactive CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), or using the [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
9093
docs/yarn.lock
Normal file
9093
docs/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,99 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
||||
import { AccountSettingsContextMenu } from "#/components/context-menu/account-settings-context-menu";
|
||||
|
||||
describe("AccountSettingsContextMenu", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
onCloseMock.mockClear();
|
||||
});
|
||||
|
||||
it("should always render the right options", () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Account Settings")).toBeInTheDocument();
|
||||
expect(screen.getByText("Logout")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsOption = screen.getByText("Account Settings");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("Logout");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("onLogout should be disabled if the user is not logged in", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logoutOption = screen.getByText("Logout");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("Account Settings");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ContextMenuListItem } from "#/components/context-menu/context-menu-list-item";
|
||||
|
||||
describe("ContextMenuListItem", () => {
|
||||
it("should render the component with the children", () => {
|
||||
render(<ContextMenuListItem onClick={vi.fn}>Test</ContextMenuListItem>);
|
||||
|
||||
expect(screen.getByTestId("context-menu-list-item")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call the onClick callback when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickMock = vi.fn();
|
||||
render(
|
||||
<ContextMenuListItem onClick={onClickMock}>Test</ContextMenuListItem>,
|
||||
);
|
||||
|
||||
const element = screen.getByTestId("context-menu-list-item");
|
||||
await user.click(element);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should not call the onClick callback when clicked and the button is disabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickMock = vi.fn();
|
||||
render(
|
||||
<ContextMenuListItem onClick={onClickMock} isDisabled>
|
||||
Test
|
||||
</ContextMenuListItem>,
|
||||
);
|
||||
|
||||
const element = screen.getByTestId("context-menu-list-item");
|
||||
await user.click(element);
|
||||
|
||||
expect(onClickMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -16,7 +16,7 @@ vi.mock("../../services/fileService", async () => ({
|
||||
}));
|
||||
|
||||
const renderFileExplorerWithRunningAgentState = () =>
|
||||
renderWithProviders(<FileExplorer />, {
|
||||
renderWithProviders(<FileExplorer error={null} />, {
|
||||
preloadedState: {
|
||||
agent: {
|
||||
curAgentState: AgentState.RUNNING,
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("AIConfigForm", () => {
|
||||
it.todo("should render the AI config form");
|
||||
it.todo("should toggle the advanced settings when clicked");
|
||||
it.todo("should call the onSubmit callback when the form is submitted");
|
||||
it.todo("should call the onReset callback when the reset button is clicked");
|
||||
it.todo("should call the onClose callback when the close button is clicked");
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("DropdownInput", () => {
|
||||
it.todo("should render the input");
|
||||
it.todo("should render the placeholder");
|
||||
it.todo("should render the dropdown when clicked");
|
||||
it.todo("should select an option when clicked");
|
||||
it.todo("should filter the options when typing");
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ModelSelector", () => {
|
||||
it.todo("should render the model selector");
|
||||
it.todo("should display and select the providers");
|
||||
it.todo("should display and select the models");
|
||||
it.todo("should disable the models if a provider is not selected");
|
||||
it.todo("should disable the inputs if isDisabled is true");
|
||||
it.todo(
|
||||
"should set the selected model and provider if the currentModel prop is set",
|
||||
);
|
||||
});
|
||||
132
frontend/__tests__/components/user-actions.test.tsx
Normal file
132
frontend/__tests__/components/user-actions.test.tsx
Normal file
@ -0,0 +1,132 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, test, vi, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import * as Remix from "@remix-run/react";
|
||||
import { UserActions } from "#/components/user-actions";
|
||||
|
||||
describe("UserActions", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
|
||||
const useFetcherSpy = vi.spyOn(Remix, "useFetcher");
|
||||
// @ts-expect-error - Only returning the relevant properties for the test
|
||||
useFetcherSpy.mockReturnValue({ state: "idle" });
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
useFetcherSpy.mockClear();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(userAvatar);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const accountSettingsOption = screen.getByText("Account Settings");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("Logout");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("onLogout should not be called when the user is not logged in", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("Logout");
|
||||
await user.click(logoutOption);
|
||||
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the loading spinner", () => {
|
||||
// @ts-expect-error - Only returning the relevant properties for the test
|
||||
useFetcherSpy.mockReturnValue({ state: "loading" });
|
||||
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
user.click(userAvatar);
|
||||
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
68
frontend/__tests__/components/user-avatar.test.tsx
Normal file
68
frontend/__tests__/components/user-avatar.test.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { UserAvatar } from "#/components/user-avatar";
|
||||
|
||||
describe("UserAvatar", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
onClickMock.mockClear();
|
||||
});
|
||||
|
||||
it("(default) should render the placeholder avatar when the user is logged out", () => {
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("user avatar placeholder"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UserAvatar onClick={onClickMock} />);
|
||||
|
||||
const userAvatarContainer = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatarContainer);
|
||||
|
||||
expect(onClickMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should display the user's avatar when available", () => {
|
||||
render(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByAltText("user avatar")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("user avatar placeholder"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display a loading spinner instead of an avatar when isLoading is true", () => {
|
||||
const { rerender } = render(<UserAvatar onClick={onClickMock} />);
|
||||
expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByLabelText("user avatar placeholder"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<UserAvatar onClick={onClickMock} isLoading />);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByLabelText("user avatar placeholder"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<UserAvatar
|
||||
onClick={onClickMock}
|
||||
avatarUrl="https://example.com/avatar.png"
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
36
frontend/__tests__/hooks/use-click-outside-element.test.tsx
Normal file
36
frontend/__tests__/hooks/use-click-outside-element.test.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
|
||||
interface ClickOutsideTestComponentProps {
|
||||
callback: () => void;
|
||||
}
|
||||
|
||||
function ClickOutsideTestComponent({
|
||||
callback,
|
||||
}: ClickOutsideTestComponentProps) {
|
||||
const ref = useClickOutsideElement<HTMLDivElement>(callback);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="inside-element" ref={ref} />
|
||||
<div data-testid="outside-element" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
test("call the callback when the element is clicked outside", async () => {
|
||||
const user = userEvent.setup();
|
||||
const callback = vi.fn();
|
||||
render(<ClickOutsideTestComponent callback={callback} />);
|
||||
|
||||
const insideElement = screen.getByTestId("inside-element");
|
||||
const outsideElement = screen.getByTestId("outside-element");
|
||||
|
||||
await user.click(insideElement);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(outsideElement);
|
||||
expect(callback).toHaveBeenCalled();
|
||||
});
|
||||
35
frontend/__tests__/routes/_oh.test.tsx
Normal file
35
frontend/__tests__/routes/_oh.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, it, test } from "vitest";
|
||||
|
||||
describe("frontend/routes/_oh", () => {
|
||||
describe("brand logo", () => {
|
||||
it.todo("should not do anything if the user is in the main screen");
|
||||
it.todo(
|
||||
"should be clickable and redirect to the main screen if the user is not in the main screen",
|
||||
);
|
||||
});
|
||||
|
||||
describe("user menu", () => {
|
||||
it.todo("should open the user menu when clicked");
|
||||
|
||||
describe("logged out", () => {
|
||||
it.todo("should display a placeholder");
|
||||
test.todo("the logout option in the user menu should be disabled");
|
||||
});
|
||||
|
||||
describe("logged in", () => {
|
||||
it.todo("should display the user's avatar");
|
||||
it.todo("should log the user out when the logout option is clicked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it.todo("should open the config modal when clicked");
|
||||
it.todo(
|
||||
"should not save the config and close the config modal when the close button is clicked",
|
||||
);
|
||||
it.todo(
|
||||
"should save the config when the save button is clicked and close the modal",
|
||||
);
|
||||
it.todo("should warn the user about saving the config when in /app");
|
||||
});
|
||||
});
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@nextui-org/react": "^2.4.8",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@ -62,7 +62,7 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
const response = await fetch(`${OpenHands.BASE_URL}/config.json`, {
|
||||
const response = await fetch("config.json", {
|
||||
headers: {
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
|
||||
@ -1,30 +1,34 @@
|
||||
import { ContextMenu } from "./context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu/context-menu-list-item";
|
||||
import { ContextMenuSeparator } from "./context-menu/context-menu-separator";
|
||||
import { ContextMenu } from "./context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu-list-item";
|
||||
import { ContextMenuSeparator } from "./context-menu-separator";
|
||||
import { useClickOutsideElement } from "#/hooks/useClickOutsideElement";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
isLoggedIn: boolean;
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
onClose: () => void;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AccountSettingsContextMenu({
|
||||
isLoggedIn,
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
onClose,
|
||||
isLoggedIn,
|
||||
}: AccountSettingsContextMenuProps) {
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} className="absolute left-full -top-1 z-10">
|
||||
<ContextMenu
|
||||
testId="account-settings-context-menu"
|
||||
ref={ref}
|
||||
className="absolute left-full -top-1 z-10"
|
||||
>
|
||||
<ContextMenuListItem onClick={onClickAccountSettings}>
|
||||
Account Settings
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuListItem disabled={!isLoggedIn} onClick={onLogout}>
|
||||
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
|
||||
Logout
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
@ -1,25 +1,25 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuListItemProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ContextMenuListItem({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
}: ContextMenuListItemProps) {
|
||||
isDisabled,
|
||||
}: React.PropsWithChildren<ContextMenuListItemProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid="context-menu-list-item"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@ -2,13 +2,15 @@ import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuProps {
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
}
|
||||
|
||||
export const ContextMenu = React.forwardRef<HTMLUListElement, ContextMenuProps>(
|
||||
({ children, className }, ref) => (
|
||||
({ testId, children, className }, ref) => (
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-[#404040] rounded-md w-[224px]", className)}
|
||||
>
|
||||
|
||||
@ -90,12 +90,17 @@ function ExplorerActions({
|
||||
);
|
||||
}
|
||||
|
||||
function FileExplorer() {
|
||||
interface FileExplorerProps {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function FileExplorer({ error }: FileExplorerProps) {
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const { paths, setPaths } = useFiles();
|
||||
const [isHidden, setIsHidden] = React.useState(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
@ -158,7 +163,7 @@ function FileExplorer() {
|
||||
|
||||
refreshWorkspace();
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (e) {
|
||||
// Handle unexpected errors (network issues, etc.)
|
||||
toast.error(
|
||||
`upload-error-${new Date().getTime()}`,
|
||||
@ -230,11 +235,18 @@ function FileExplorer() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-auto flex-grow">
|
||||
<div style={{ display: isHidden ? "none" : "block" }}>
|
||||
<ExplorerTree files={paths} />
|
||||
{!error && (
|
||||
<div className="overflow-auto flex-grow">
|
||||
<div style={{ display: isHidden ? "none" : "block" }}>
|
||||
<ExplorerTree files={paths} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-300 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
data-testid="file-input"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import { RootState } from "#/store";
|
||||
import FolderIcon from "../FolderIcon";
|
||||
import FileIcon from "../FileIcons";
|
||||
@ -18,7 +19,7 @@ function Title({ name, type, isOpen, onClick }: TitleProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white"
|
||||
className="cursor-pointer text-nowrap rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{type === "folder" && <FolderIcon isOpen={isOpen} />}
|
||||
@ -60,8 +61,12 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
const newChildren = await OpenHands.getFiles(token, path);
|
||||
setChildren(newChildren);
|
||||
try {
|
||||
const newChildren = await OpenHands.getFiles(token, path);
|
||||
setChildren(newChildren);
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch files");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -77,12 +82,16 @@ function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
if (isDirectory) {
|
||||
setIsOpen((prev) => !prev);
|
||||
} else if (token) {
|
||||
setSelectedPath(path);
|
||||
const code = modifiedFiles[path] || files[path];
|
||||
const fetchedCode = await OpenHands.getFile(token, path);
|
||||
|
||||
if (!code || fetchedCode !== files[path]) {
|
||||
setFileContent(path, fetchedCode);
|
||||
try {
|
||||
const fetchedCode = await OpenHands.getFile(token, path);
|
||||
setSelectedPath(path);
|
||||
if (!code || fetchedCode !== files[path]) {
|
||||
setFileContent(path, fetchedCode);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Failed to fetch file");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -4,16 +4,16 @@ import {
|
||||
Input,
|
||||
Switch,
|
||||
} from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import { useFetcher, useLocation, useNavigate } from "@remix-run/react";
|
||||
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { Settings } from "#/services/settings";
|
||||
import clsx from "clsx";
|
||||
import React from "react";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import { ModelSelector } from "#/components/modals/settings/ModelSelector";
|
||||
import { clientAction } from "#/routes/settings";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { extractModelAndProvider } from "#/utils/extractModelAndProvider";
|
||||
import { organizeModelsAndProviders } from "#/utils/organizeModelsAndProviders";
|
||||
import ModalButton from "../buttons/ModalButton";
|
||||
import { DangerModal } from "../modals/confirmation-modals/danger-modal";
|
||||
|
||||
interface SettingsFormProps {
|
||||
@ -44,9 +44,8 @@ export function SettingsForm({
|
||||
navigate("/");
|
||||
onClose();
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
}, [fetcher.data, navigate, onClose]);
|
||||
|
||||
// Figure out if the advanced options should be enabled by default
|
||||
const advancedAlreadyInUse = React.useMemo(() => {
|
||||
if (models.length > 0) {
|
||||
const organizedModels = organizeModelsAndProviders(models);
|
||||
@ -79,6 +78,7 @@ export function SettingsForm({
|
||||
React.useState(false);
|
||||
const [confirmEndSessionModalOpen, setConfirmEndSessionModalOpen] =
|
||||
React.useState(false);
|
||||
const [showWarningModal, setShowWarningModal] = React.useState(false);
|
||||
|
||||
const submitForm = (formData: FormData) => {
|
||||
if (location.pathname === "/app") formData.set("end-session", "true");
|
||||
@ -98,15 +98,41 @@ export function SettingsForm({
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
const apiKey = formData.get("api-key");
|
||||
|
||||
if (location.pathname === "/app") {
|
||||
if (!apiKey) {
|
||||
setShowWarningModal(true);
|
||||
} else if (location.pathname === "/app") {
|
||||
setConfirmEndSessionModalOpen(true);
|
||||
} else {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
submitForm(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseClick = () => {
|
||||
const formData = new FormData(formRef.current ?? undefined);
|
||||
const apiKey = formData.get("api-key");
|
||||
|
||||
if (!apiKey) {
|
||||
setShowWarningModal(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarningConfirm = () => {
|
||||
setShowWarningModal(false);
|
||||
const formData = new FormData(formRef.current ?? undefined);
|
||||
formData.set("api-key", ""); // Set null value for API key
|
||||
submitForm(formData);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleWarningCancel = () => {
|
||||
setShowWarningModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<fetcher.Form
|
||||
@ -125,7 +151,7 @@ export function SettingsForm({
|
||||
onValueChange={setShowAdvancedOptions}
|
||||
classNames={{
|
||||
thumb: clsx(
|
||||
"bg-[#5D5D5D] w-3 h-3",
|
||||
"bg-[#5D5D5D] w-3 h-3 z-0",
|
||||
"group-data-[selected=true]:bg-white",
|
||||
),
|
||||
wrapper: clsx(
|
||||
@ -325,7 +351,7 @@ export function SettingsForm({
|
||||
<ModalButton
|
||||
text="Close"
|
||||
className="bg-[#737373] w-full"
|
||||
onClick={onClose}
|
||||
onClick={handleCloseClick}
|
||||
/>
|
||||
</div>
|
||||
<ModalButton
|
||||
@ -373,6 +399,24 @@ export function SettingsForm({
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
{showWarningModal && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
title="Are you sure?"
|
||||
description="You haven't set an API key. Without an API key, you won't be able to use the AI features. Are you sure you want to close the settings?"
|
||||
buttons={{
|
||||
danger: {
|
||||
text: "Yes, close settings",
|
||||
onClick: handleWarningConfirm,
|
||||
},
|
||||
cancel: {
|
||||
text: "Cancel",
|
||||
onClick: handleWarningCancel,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ModalBackdrop>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ export function LoadingSpinner({ size }: LoadingSpinnerProps) {
|
||||
size === "small" ? "w-[25px] h-[25px]" : "w-[50px] h-[50px]";
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizeStyle)}>
|
||||
<div data-testid="loading-spinner" className={cn("relative", sizeStyle)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-full border-4 border-[#525252] absolute",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import EllipsisH from "#/assets/ellipsis-h.svg?react";
|
||||
import { ModalBackdrop } from "../modals/modal-backdrop";
|
||||
import { ConnectToGitHubModal } from "../modals/connect-to-github-modal";
|
||||
@ -64,7 +65,13 @@ Finally, open up a pull request using the GitHub API and the token in the GITHUB
|
||||
isConnectedToGitHub={isConnectedToGitHub}
|
||||
onConnectToGitHub={() => setConnectToGitHubModalOpen(true)}
|
||||
onPushToGitHub={handlePushToGitHub}
|
||||
onDownloadWorkspace={downloadWorkspace}
|
||||
onDownloadWorkspace={() => {
|
||||
try {
|
||||
downloadWorkspace();
|
||||
} catch (error) {
|
||||
toast.error("Failed to download workspace");
|
||||
}
|
||||
}}
|
||||
onClose={() => setContextMenuIsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
58
frontend/src/components/user-actions.tsx
Normal file
58
frontend/src/components/user-actions.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { AccountSettingsContextMenu } from "./context-menu/account-settings-context-menu";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
|
||||
interface UserActionsProps {
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
user?: { avatar_url: string };
|
||||
}
|
||||
|
||||
export function UserActions({
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
user,
|
||||
}: UserActionsProps) {
|
||||
const loginFetcher = useFetcher({ key: "login" });
|
||||
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const toggleAccountMenu = () => {
|
||||
setAccountContextMenuIsVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
const closeAccountMenu = () => {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
};
|
||||
|
||||
const handleClickAccountSettings = () => {
|
||||
onClickAccountSettings();
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="user-actions" className="w-8 h-8 relative">
|
||||
<UserAvatar
|
||||
isLoading={loginFetcher.state !== "idle"}
|
||||
avatarUrl={user?.avatar_url}
|
||||
onClick={toggleAccountMenu}
|
||||
/>
|
||||
|
||||
{accountContextMenuIsVisible && (
|
||||
<AccountSettingsContextMenu
|
||||
isLoggedIn={!!user}
|
||||
onClickAccountSettings={handleClickAccountSettings}
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,68 +1,39 @@
|
||||
import { cn } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { isGitHubErrorReponse } from "#/api/github";
|
||||
import { AccountSettingsContextMenu } from "./account-settings-context-menu";
|
||||
import { LoadingSpinner } from "./modals/LoadingProject";
|
||||
import DefaultUserAvatar from "#/assets/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface UserAvatarProps {
|
||||
isLoading: boolean;
|
||||
user: GitHubUser | GitHubErrorReponse | null;
|
||||
onLogout: () => void;
|
||||
handleOpenAccountSettingsModal: () => void;
|
||||
onClick: () => void;
|
||||
avatarUrl?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
isLoading,
|
||||
user,
|
||||
onLogout,
|
||||
handleOpenAccountSettingsModal,
|
||||
}: UserAvatarProps) {
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
const validUser = user && !isGitHubErrorReponse(user);
|
||||
|
||||
const handleClickUserAvatar = () => {
|
||||
setAccountContextMenuIsVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
return (
|
||||
<div className="w-8 h-8 relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"bg-white w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
onClick={handleClickUserAvatar}
|
||||
>
|
||||
{!validUser && !isLoading && (
|
||||
<DefaultUserAvatar width={20} height={20} />
|
||||
)}
|
||||
{!validUser && isLoading && <LoadingSpinner size="small" />}
|
||||
{validUser && (
|
||||
<img
|
||||
src={user.avatar_url}
|
||||
alt="User avatar"
|
||||
className="w-full h-full rounded-full"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{accountContextMenuIsVisible && (
|
||||
<AccountSettingsContextMenu
|
||||
isLoggedIn={!!user}
|
||||
onClose={() => setAccountContextMenuIsVisible(false)}
|
||||
onClickAccountSettings={() => {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
handleOpenAccountSettingsModal();
|
||||
}}
|
||||
onLogout={() => {
|
||||
onLogout();
|
||||
setAccountContextMenuIsVisible(false);
|
||||
}}
|
||||
<button
|
||||
data-testid="user-avatar"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"bg-white w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
>
|
||||
{!isLoading && avatarUrl && (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="user avatar"
|
||||
className="w-full h-full rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isLoading && !avatarUrl && (
|
||||
<DefaultUserAvatar
|
||||
aria-label="user avatar placeholder"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,21 +1,25 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
|
||||
export const handlers = [
|
||||
http.get("https://api.github.com/user/repos", ({ request }) => {
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json([], { status: 401 });
|
||||
}
|
||||
|
||||
const openHandsHandlers = [
|
||||
http.get("http://localhost:3000/api/options/models", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json([
|
||||
{ id: 1, full_name: "octocat/hello-world" },
|
||||
{ id: 2, full_name: "octocat/earth" },
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"anthropic/claude-3.5",
|
||||
]);
|
||||
}),
|
||||
|
||||
http.get("http://localhost:3000/api/options/agents", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["CodeActAgent", "CoActAgent"]);
|
||||
}),
|
||||
|
||||
http.get("http://localhost:3000/api/options/security-analyzers", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["mock-invariant"]);
|
||||
}),
|
||||
|
||||
http.get("http://localhost:3000/api/list-files", async ({ request }) => {
|
||||
await delay();
|
||||
|
||||
@ -24,14 +28,16 @@ export const handlers = [
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json([], { status: 401 });
|
||||
}
|
||||
|
||||
if (!token) return HttpResponse.json([], { status: 401 });
|
||||
return HttpResponse.json(["file1.ts", "dir1/file2.ts", "file3.ts"]);
|
||||
}),
|
||||
|
||||
http.post("http://localhost:3000/api/save-file", () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
),
|
||||
|
||||
http.get("http://localhost:3000/api/select-file", async ({ request }) => {
|
||||
await delay(500);
|
||||
await delay();
|
||||
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
@ -51,26 +57,26 @@ export const handlers = [
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
http.get("http://localhost:3000/api/options/agents", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["CodeActAgent", "CoActAgent"]);
|
||||
}),
|
||||
http.get("http://localhost:3000/api/options/models", async () => {
|
||||
await delay();
|
||||
];
|
||||
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("https://api.github.com/user/repos", ({ request }) => {
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json([], { status: 401 });
|
||||
}
|
||||
|
||||
return HttpResponse.json([
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"anthropic/claude-3.5",
|
||||
{ id: 1, full_name: "octocat/hello-world" },
|
||||
{ id: 2, full_name: "octocat/earth" },
|
||||
]);
|
||||
}),
|
||||
http.post("http://localhost:3000/api/submit-feedback", async () =>
|
||||
HttpResponse.json({ statusCode: 200 }, { status: 200 }),
|
||||
),
|
||||
http.post("http://localhost:3000/api/save-file", () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
),
|
||||
http.get("http://localhost:3000/api/options/security-analyzers", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["mock-invariant"]);
|
||||
}),
|
||||
];
|
||||
|
||||
@ -158,7 +158,7 @@ function Home() {
|
||||
className="w-full flex justify-center"
|
||||
>
|
||||
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
|
||||
Click here to load
|
||||
Upload a .zip
|
||||
</span>
|
||||
<input
|
||||
hidden
|
||||
|
||||
@ -1,15 +1,26 @@
|
||||
import React from "react";
|
||||
import { Form, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { Form, useNavigation } from "@remix-run/react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import Send from "#/assets/send.svg?react";
|
||||
import Clip from "#/assets/clip.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { RootState } from "#/store";
|
||||
import { addFile } from "#/state/initial-query-slice";
|
||||
import { addFile, setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import { SuggestionBubble } from "#/components/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
|
||||
const convertZipToBase64 = async (file: File) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
interface MainTextareaInputProps {
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
@ -88,7 +99,6 @@ interface TaskFormProps {
|
||||
export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
@ -134,16 +144,12 @@ export function TaskForm({ importedProjectZip, textareaRef }: TaskFormProps) {
|
||||
setText(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmitForm = () => {
|
||||
// This is submitted on top of the form submission
|
||||
const formData = new FormData();
|
||||
const handleSubmitForm = async () => {
|
||||
// This is handled on top of the form submission
|
||||
if (importedProjectZip) {
|
||||
formData.append("imported-project", importedProjectZip);
|
||||
fetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/upload-initial-files",
|
||||
encType: "multipart/form-data",
|
||||
});
|
||||
dispatch(
|
||||
setImportedProjectZip(await convertZipToBase64(importedProjectZip)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
import { type editor } from "monaco-editor";
|
||||
import toast from "react-hot-toast";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useFiles } from "#/context/files";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
@ -51,7 +52,7 @@ function CodeEditorCompoonent({ isReadOnly }: CodeEditorCompoonentProps) {
|
||||
const token = localStorage.getItem("token")?.toString();
|
||||
if (token) await OpenHands.saveFile(token, selectedPath, content);
|
||||
} catch (error) {
|
||||
// handle error
|
||||
toast.error("Failed to save file");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,7 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import {
|
||||
ClientActionFunctionArgs,
|
||||
json,
|
||||
useLoaderData,
|
||||
useRouteError,
|
||||
} from "@remix-run/react";
|
||||
import { json, useLoaderData, useRouteError } from "@remix-run/react";
|
||||
import toast from "react-hot-toast";
|
||||
import { RootState } from "#/store";
|
||||
import AgentState from "#/types/AgentState";
|
||||
import FileExplorer from "#/components/file-explorer/FileExplorer";
|
||||
@ -20,21 +16,6 @@ export const clientLoader = async () => {
|
||||
return json({ token });
|
||||
};
|
||||
|
||||
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file")?.toString();
|
||||
|
||||
let selectedFileContent: string | null = null;
|
||||
|
||||
if (file && token) {
|
||||
selectedFileContent = await OpenHands.getFile(token, file);
|
||||
}
|
||||
|
||||
return json({ file, selectedFileContent });
|
||||
};
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
|
||||
@ -57,13 +38,23 @@ function CodeEditor() {
|
||||
discardChanges,
|
||||
} = useFiles();
|
||||
|
||||
const [errors, setErrors] = React.useState<{ getFiles: string | null }>({
|
||||
getFiles: null,
|
||||
});
|
||||
|
||||
const agentState = useSelector(
|
||||
(state: RootState) => state.agent.curAgentState,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// only retrieve files if connected to WS to prevent requesting before runtime is ready
|
||||
if (runtimeActive && token) OpenHands.getFiles(token).then(setPaths);
|
||||
if (runtimeActive && token) {
|
||||
OpenHands.getFiles(token)
|
||||
.then(setPaths)
|
||||
.catch(() => {
|
||||
setErrors({ getFiles: "Failed to retrieve files" });
|
||||
});
|
||||
}
|
||||
}, [runtimeActive, token]);
|
||||
|
||||
// Code editing is only allowed when the agent is paused, finished, or awaiting user input (server rules)
|
||||
@ -77,13 +68,13 @@ function CodeEditor() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (selectedPath) {
|
||||
const content = saveNewFileContent(selectedPath);
|
||||
|
||||
const content = modifiedFiles[selectedPath];
|
||||
if (content && token) {
|
||||
try {
|
||||
await OpenHands.saveFile(token, selectedPath, content);
|
||||
saveNewFileContent(selectedPath);
|
||||
} catch (error) {
|
||||
// handle error
|
||||
toast.error("Failed to save file");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -95,7 +86,7 @@ function CodeEditor() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-neutral-900 relative">
|
||||
<FileExplorer />
|
||||
<FileExplorer error={errors.getFiles} />
|
||||
<div className="flex flex-col min-h-0 w-full">
|
||||
{selectedPath && (
|
||||
<div className="flex w-full items-center justify-between self-end p-2">
|
||||
|
||||
@ -35,6 +35,7 @@ import { createChatMessage } from "#/services/chatService";
|
||||
import {
|
||||
clearFiles,
|
||||
clearSelectedRepository,
|
||||
setImportedProjectZip,
|
||||
} from "#/state/initial-query-slice";
|
||||
import { isGitHubErrorReponse, retrieveLatestGitHubCommit } from "#/api/github";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
@ -66,19 +67,10 @@ export const clientLoader = async () => {
|
||||
const repo =
|
||||
store.getState().initalQuery.selectedRepository ||
|
||||
localStorage.getItem("repo");
|
||||
const importedProject = store.getState().initalQuery.importedProjectZip;
|
||||
|
||||
const settings = getSettings();
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
if (token && importedProject) {
|
||||
const blob = base64ToBlob(importedProject);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles(token, [file]);
|
||||
}
|
||||
|
||||
if (repo) localStorage.setItem("repo", repo);
|
||||
|
||||
let lastCommit: GitHubCommit | null = null;
|
||||
@ -116,7 +108,9 @@ export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
|
||||
function App() {
|
||||
const dispatch = useDispatch();
|
||||
const { files } = useSelector((state: RootState) => state.initalQuery);
|
||||
const { files, importedProjectZip } = useSelector(
|
||||
(state: RootState) => state.initalQuery,
|
||||
);
|
||||
const { start, send, setRuntimeIsInitialized, runtimeActive } = useSocket();
|
||||
const { settings, token, ghToken, repo, q, lastCommit } =
|
||||
useLoaderData<typeof clientLoader>();
|
||||
@ -223,12 +217,30 @@ function App() {
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
if (userId && ghToken && runtimeActive) {
|
||||
if (runtimeActive && userId && ghToken) {
|
||||
// Export if the user valid, this could happen mid-session so it is handled here
|
||||
send(getGitHubTokenCommand(ghToken));
|
||||
}
|
||||
}, [userId, ghToken, runtimeActive]);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (runtimeActive && token && importedProjectZip) {
|
||||
// upload files action
|
||||
try {
|
||||
const blob = base64ToBlob(importedProjectZip);
|
||||
const file = new File([blob], "imported-project.zip", {
|
||||
type: blob.type,
|
||||
});
|
||||
await OpenHands.uploadFiles(token, [file]);
|
||||
dispatch(setImportedProjectZip(null));
|
||||
} catch (error) {
|
||||
toast.error("Failed to upload project files.");
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [runtimeActive, token, importedProjectZip]);
|
||||
|
||||
const {
|
||||
isOpen: securityModalIsOpen,
|
||||
onOpen: onSecurityModalOpen,
|
||||
|
||||
@ -17,7 +17,7 @@ import AccountSettingsModal from "#/components/modals/AccountSettingsModal";
|
||||
import { DangerModal } from "#/components/modals/confirmation-modals/danger-modal";
|
||||
import { LoadingSpinner } from "#/components/modals/LoadingProject";
|
||||
import { ModalBackdrop } from "#/components/modals/modal-backdrop";
|
||||
import { UserAvatar } from "#/components/user-avatar";
|
||||
import { UserActions } from "#/components/user-actions";
|
||||
import { useSocket } from "#/context/socket";
|
||||
import i18n from "#/i18n";
|
||||
import { getSettings, settingsAreUpToDate } from "#/services/settings";
|
||||
@ -91,13 +91,18 @@ export function ErrorBoundary() {
|
||||
);
|
||||
}
|
||||
|
||||
type SettingsFormData = {
|
||||
models: string[];
|
||||
agents: string[];
|
||||
securityAnalyzers: string[];
|
||||
};
|
||||
|
||||
export default function MainApp() {
|
||||
const { stop, isConnected } = useSocket();
|
||||
const navigation = useNavigation();
|
||||
const location = useLocation();
|
||||
const { token, user, settingsIsUpdated, settings } =
|
||||
useLoaderData<typeof clientLoader>();
|
||||
const loginFetcher = useFetcher({ key: "login" });
|
||||
const logoutFetcher = useFetcher({ key: "logout" });
|
||||
const endSessionFetcher = useFetcher({ key: "end-session" });
|
||||
|
||||
@ -106,28 +111,31 @@ export default function MainApp() {
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
React.useState(false);
|
||||
const [data, setData] = React.useState<{
|
||||
models: string[];
|
||||
agents: string[];
|
||||
securityAnalyzers: string[];
|
||||
}>({
|
||||
models: [],
|
||||
agents: [],
|
||||
securityAnalyzers: [],
|
||||
});
|
||||
const [settingsFormData, setSettingsFormData] =
|
||||
React.useState<SettingsFormData>({
|
||||
models: [],
|
||||
agents: [],
|
||||
securityAnalyzers: [],
|
||||
});
|
||||
const [settingsFormError, setSettingsFormError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
// We fetch this here instead of the data loader because the server seems to block
|
||||
// the retrieval when the session is closing -- preventing the screen from rendering until
|
||||
// the fetch is complete
|
||||
(async () => {
|
||||
const [models, agents, securityAnalyzers] = await Promise.all([
|
||||
OpenHands.getModels(),
|
||||
OpenHands.getAgents(),
|
||||
OpenHands.getSecurityAnalyzers(),
|
||||
]);
|
||||
|
||||
setData({ models, agents, securityAnalyzers });
|
||||
try {
|
||||
const [models, agents, securityAnalyzers] = await Promise.all([
|
||||
OpenHands.getModels(),
|
||||
OpenHands.getAgents(),
|
||||
OpenHands.getSecurityAnalyzers(),
|
||||
]);
|
||||
setSettingsFormData({ models, agents, securityAnalyzers });
|
||||
} catch (error) {
|
||||
setSettingsFormError("Failed to load settings, please reload the page");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@ -192,13 +200,14 @@ export default function MainApp() {
|
||||
)}
|
||||
</div>
|
||||
<nav className="py-[18px] flex flex-col items-center gap-[18px]">
|
||||
<UserAvatar
|
||||
user={user}
|
||||
isLoading={loginFetcher.state !== "idle"}
|
||||
onLogout={handleUserLogout}
|
||||
handleOpenAccountSettingsModal={() =>
|
||||
setAccountSettingsModalOpen(true)
|
||||
<UserActions
|
||||
user={
|
||||
user && !isGitHubErrorReponse(user)
|
||||
? { avatar_url: user.avatar_url }
|
||||
: undefined
|
||||
}
|
||||
onLogout={handleUserLogout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@ -233,6 +242,9 @@ export default function MainApp() {
|
||||
{(!settingsIsUpdated || settingsModalIsOpen) && (
|
||||
<ModalBackdrop onClose={() => setSettingsModalIsOpen(false)}>
|
||||
<div className="bg-root-primary w-[384px] p-6 rounded-xl flex flex-col gap-2">
|
||||
{settingsFormError && (
|
||||
<p className="text-danger text-xs">{settingsFormError}</p>
|
||||
)}
|
||||
<span className="text-xl leading-6 font-semibold -tracking-[0.01em">
|
||||
AI Provider Configuration
|
||||
</span>
|
||||
@ -247,9 +259,9 @@ export default function MainApp() {
|
||||
)}
|
||||
<SettingsForm
|
||||
settings={settings}
|
||||
models={data.models}
|
||||
agents={data.agents}
|
||||
securityAnalyzers={data.securityAnalyzers}
|
||||
models={settingsFormData.models}
|
||||
agents={settingsFormData.agents}
|
||||
securityAnalyzers={settingsFormData.securityAnalyzers}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
import { ClientActionFunctionArgs, json } from "@remix-run/react";
|
||||
import { setImportedProjectZip } from "#/state/initial-query-slice";
|
||||
import store from "#/store";
|
||||
|
||||
const convertZipToBase64 = async (file: File) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
return new Promise<string>((resolve) => {
|
||||
reader.onload = () => {
|
||||
resolve(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
};
|
||||
|
||||
export const clientAction = async ({ request }: ClientActionFunctionArgs) => {
|
||||
const isMultipart = !!request.headers
|
||||
.get("Content-Type")
|
||||
?.includes("multipart");
|
||||
|
||||
if (isMultipart) {
|
||||
const formData = await request.formData();
|
||||
const importedProject = formData.get("imported-project");
|
||||
|
||||
if (importedProject instanceof File) {
|
||||
store.dispatch(
|
||||
setImportedProjectZip(await convertZipToBase64(importedProject)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return json(null);
|
||||
};
|
||||
@ -4,22 +4,18 @@ import OpenHands from "#/api/open-hands";
|
||||
* Downloads the current workspace as a .zip file.
|
||||
*/
|
||||
export const downloadWorkspace = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
throw new Error("No token found");
|
||||
}
|
||||
|
||||
const blob = await OpenHands.getWorkspaceZip(token);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", "workspace.zip");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
} catch (e) {
|
||||
console.error("Failed to download workspace as .zip", e);
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
throw new Error("No token found");
|
||||
}
|
||||
|
||||
const blob = await OpenHands.getWorkspaceZip(token);
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", "workspace.zip");
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.parentNode?.removeChild(link);
|
||||
};
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = ["OpenHands"]
|
||||
license = "MIT"
|
||||
@ -89,7 +89,6 @@ reportlab = "*"
|
||||
[tool.coverage.run]
|
||||
concurrency = ["gevent"]
|
||||
|
||||
|
||||
[tool.poetry.group.runtime.dependencies]
|
||||
jupyterlab = "*"
|
||||
notebook = "*"
|
||||
@ -120,7 +119,6 @@ ignore = ["D1"]
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
|
||||
[tool.poetry.group.evaluation.dependencies]
|
||||
streamlit = "*"
|
||||
whatthepatch = "*"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user