Merge branch 'main' into tobitege/fn_calling

This commit is contained in:
tobitege 2024-10-23 00:46:06 +02:00 committed by GitHub
commit 864894815f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 9910 additions and 308 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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 youve already pulled or one youve 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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -16,7 +16,7 @@ vi.mock("../../services/fileService", async () => ({
}));
const renderFileExplorerWithRunningAgentState = () =>
renderWithProviders(<FileExplorer />, {
renderWithProviders(<FileExplorer error={null} />, {
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.10.0",
"version": "0.11.0",
"private": true,
"type": "module",
"engines": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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