mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0e5792542 | |||
| c17f8a6d68 | |||
| 336c5d3c2b | |||
| baf2f766f4 | |||
| dfb1731922 | |||
| be497a34ed |
+1
-1
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.18-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.17-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -43,17 +43,17 @@ See the [Installation](https://docs.all-hands.dev/modules/usage/installation) gu
|
||||
system requirements and more information.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
@@ -64,16 +64,16 @@ works best, but you have [many options](https://docs.all-hands.dev/modules/usage
|
||||
|
||||
---
|
||||
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
|
||||
or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
or run it on tagged issues with [a github action](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/resolver/README.md).
|
||||
|
||||
Visit [Installation](https://docs.all-hands.dev/modules/usage/installation) for more information and setup instructions.
|
||||
|
||||
> [!CAUTION]
|
||||
> OpenHands is meant to be run by a single user on their local workstation.
|
||||
> It is not appropriate for multi-tenant deployments where multiple users share the same instance. There is no built-in isolation or scalability.
|
||||
> It is not appropriate for multi-tenant deployments, where multiple users share the same instance--there is no built-in isolation or scalability.
|
||||
>
|
||||
> If you're interested in running OpenHands in a multi-tenant environment, please
|
||||
> [get in touch with us](https://docs.google.com/forms/d/e/1FAIpQLSet3VbGaz8z32gW9Wm-Grl4jpt5WgMXPgJ4EDPVmCETCBpJtQ/viewform)
|
||||
@@ -86,7 +86,7 @@ Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/modules/us
|
||||
## 📖 Documentation
|
||||
|
||||
To learn more about the project, and for tips on using OpenHands,
|
||||
check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started).
|
||||
**check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-started)**.
|
||||
|
||||
There you'll find resources on how to use different LLM providers,
|
||||
troubleshooting resources, and advanced configuration options.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
- BACKEND_HOST=${BACKEND_HOST:-"0.0.0.0"}
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.18-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.17-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -61,7 +61,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) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -56,6 +56,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) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -59,7 +59,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) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -57,6 +57,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) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -6,9 +6,10 @@ This mode is different from the [headless mode](headless-mode), which is non-int
|
||||
|
||||
## With Python
|
||||
|
||||
To start an interactive OpenHands session via the command line:
|
||||
To start an interactive OpenHands session via the command line, follow these steps:
|
||||
|
||||
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
|
||||
2. Run the following command:
|
||||
|
||||
```bash
|
||||
@@ -20,32 +21,45 @@ This command will start an interactive session where you can input tasks and rec
|
||||
You'll need to be sure to set your model, API key, and other settings via environment variables
|
||||
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
|
||||
|
||||
|
||||
## With Docker
|
||||
|
||||
To run OpenHands in CLI mode with Docker:
|
||||
To run OpenHands in CLI mode with Docker, follow these steps:
|
||||
|
||||
1. Set the following environmental variables in your terminal:
|
||||
1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit:
|
||||
|
||||
* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`).
|
||||
* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`).
|
||||
* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`).
|
||||
```bash
|
||||
WORKSPACE_BASE=$(pwd)/workspace
|
||||
```
|
||||
|
||||
2. Run the following Docker command:
|
||||
2. Set `LLM_MODEL` to the model you want to use:
|
||||
|
||||
```bash
|
||||
LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"
|
||||
|
||||
```
|
||||
|
||||
3. Set `LLM_API_KEY` to your API key:
|
||||
|
||||
```bash
|
||||
LLM_API_KEY="sk_test_12345"
|
||||
```
|
||||
|
||||
4. Run the following Docker command:
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
-e LLM_MODEL=$LLM_MODEL \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -7,11 +7,12 @@ This is different from [CLI Mode](cli-mode), which is interactive, and better fo
|
||||
|
||||
## With Python
|
||||
|
||||
To run OpenHands in headless mode with Python:
|
||||
1. Ensure you have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
2. Run the following command:
|
||||
To run OpenHands in headless mode with Python,
|
||||
[follow the Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md),
|
||||
and then run:
|
||||
|
||||
```bash
|
||||
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
poetry run python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
You'll need to be sure to set your model, API key, and other settings via environment variables
|
||||
@@ -19,20 +20,31 @@ You'll need to be sure to set your model, API key, and other settings via enviro
|
||||
|
||||
## With Docker
|
||||
|
||||
To run OpenHands in Headless mode with Docker:
|
||||
1. Set `WORKSPACE_BASE` to the directory you want OpenHands to edit:
|
||||
|
||||
1. Set the following environmental variables in your terminal:
|
||||
```bash
|
||||
WORKSPACE_BASE=$(pwd)/workspace
|
||||
```
|
||||
|
||||
* `WORKSPACE_BASE` to the directory you want OpenHands to edit (Ex: `export WORKSPACE_BASE=$(pwd)/workspace`).
|
||||
* `LLM_MODEL` to the model to use (Ex: `export LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"`).
|
||||
* `LLM_API_KEY` to the API key (Ex: `export LLM_API_KEY="sk_test_12345"`).
|
||||
2. Set `LLM_MODEL` to the model you want to use:
|
||||
|
||||
2. Run the following Docker command:
|
||||
```bash
|
||||
LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"
|
||||
|
||||
```
|
||||
|
||||
3. Set `LLM_API_KEY` to your API key:
|
||||
|
||||
```bash
|
||||
LLM_API_KEY="sk_test_12345"
|
||||
```
|
||||
|
||||
4. Run the following Docker command:
|
||||
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -40,17 +52,8 @@ docker run -it \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v $WORKSPACE_BASE:/opt/workspace_base \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
## Advanced Headless Configurations
|
||||
|
||||
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
|
||||
|
||||
### Additional Logs
|
||||
|
||||
For the headless mode to log all the agent actions, in your terminal run: `export LOG_ALL_EVENTS=true`
|
||||
|
||||
@@ -11,25 +11,20 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands-state:/.openhands-state \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.18
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.17
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
You can also [connect OpenHands to your local filesystem](https://docs.all-hands.dev/modules/usage/runtimes#connecting-to-your-filesystem),
|
||||
run OpenHands in a scriptable [headless mode](https://docs.all-hands.dev/modules/usage/how-to/headless-mode),
|
||||
interact with it via a [friendly CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),
|
||||
or run it on tagged issues with [a github action](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
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).
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.18-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.17-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, test, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
|
||||
|
||||
describe("ConversationCard", () => {
|
||||
const onClick = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const onChangeTitle = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the conversation card", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
const expectedDate = `${formatTimeDelta(new Date("2021-10-01T12:00:00Z"))} ago`;
|
||||
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
const title = within(card).getByTestId("conversation-card-title");
|
||||
|
||||
expect(title).toHaveValue("Conversation 1");
|
||||
within(card).getByText(expectedDate);
|
||||
});
|
||||
|
||||
it("should render the repo if available", () => {
|
||||
const { rerender } = render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-card-repo"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("conversation-card-repo");
|
||||
});
|
||||
|
||||
it("should call onClick when the card is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const card = screen.getByTestId("conversation-card");
|
||||
await user.click(card);
|
||||
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should toggle a context menu when clicking the ellipsis button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onClick={onClick}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
screen.getByTestId("context-menu");
|
||||
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onDelete when the delete button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the repo should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo="org/repo"
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const repo = screen.getByTestId("conversation-card-repo");
|
||||
await user.click(repo);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("conversation title should call onChangeTitle when changed and blurred", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
onChangeTitle={onChangeTitle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
await user.clear(title);
|
||||
await user.type(title, "New Conversation Name ");
|
||||
await user.tab();
|
||||
|
||||
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
|
||||
expect(title).toHaveValue("New Conversation Name");
|
||||
});
|
||||
|
||||
it("should reset title and not call onChangeTitle when the title is empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
await user.clear(title);
|
||||
await user.tab();
|
||||
|
||||
expect(onChangeTitle).not.toHaveBeenCalled();
|
||||
expect(title).toHaveValue("Conversation 1");
|
||||
});
|
||||
|
||||
test("clicking the title should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
await user.click(title);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("clicking the delete button should not trigger the onClick handler", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const menu = screen.getByTestId("context-menu");
|
||||
const deleteButton = within(menu).getByTestId("delete-button");
|
||||
|
||||
await user.click(deleteButton);
|
||||
|
||||
expect(onClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("state indicator", () => {
|
||||
it("should render the 'cold' indicator by default", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
screen.getByTestId("cold-indicator");
|
||||
});
|
||||
|
||||
it("should render the other indicators when provided", () => {
|
||||
render(
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
name="Conversation 1"
|
||||
repo={null}
|
||||
lastUpdated="2021-10-01T12:00:00Z"
|
||||
state="warm"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("cold-indicator")).not.toBeInTheDocument();
|
||||
screen.getByTestId("warm-indicator");
|
||||
});
|
||||
});
|
||||
});
|
||||
-267
@@ -1,267 +0,0 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
QueryClientProvider,
|
||||
QueryClient,
|
||||
QueryClientConfig,
|
||||
} from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
const renderConversationPanel = (config?: QueryClientConfig) =>
|
||||
render(<ConversationPanel onClose={onCloseMock} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={new QueryClient(config)}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const { endSessionMock } = vi.hoisted(() => ({
|
||||
endSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
Link: ({ children }: React.PropsWithChildren) => children,
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
useLocation: vi.fn(() => ({ pathname: "/conversation" })),
|
||||
useParams: vi.fn(() => ({ conversationId: "2" })),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-end-session", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("#/hooks/use-end-session")>()),
|
||||
useEndSession: vi.fn(() => endSessionMock),
|
||||
}));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should render the conversations", async () => {
|
||||
renderConversationPanel();
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue([]);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const emptyState = await screen.findByText("No conversations found");
|
||||
expect(emptyState).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle an error when fetching conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch conversations"),
|
||||
);
|
||||
|
||||
renderConversationPanel({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const error = await screen.findByText("Failed to fetch conversations");
|
||||
expect(error).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel deleting a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
let cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(
|
||||
within(cards[0]).queryByTestId("delete-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
const deleteButton = screen.getByTestId("delete-button");
|
||||
|
||||
// Click the first delete button
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Cancel the deletion
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(screen.queryByText("Cancel")).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation is not deleted
|
||||
cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should call endSession after deleting a conversation that is the current session", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
let cards = await screen.findAllByTestId("conversation-card");
|
||||
const ellipsisButton = within(cards[1]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
const deleteButton = screen.getByTestId("delete-button");
|
||||
|
||||
// Click the second delete button
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Confirm the deletion
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation is deleted
|
||||
cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(2);
|
||||
|
||||
expect(endSessionMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should delete a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
let cards = await screen.findAllByTestId("conversation-card");
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
const deleteButton = screen.getByTestId("delete-button");
|
||||
|
||||
// Click the first delete button
|
||||
await user.click(deleteButton);
|
||||
|
||||
// Confirm the deletion
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation is deleted
|
||||
cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should rename a conversation", async () => {
|
||||
const updateUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"updateUserConversation",
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
const title = within(cards[0]).getByTestId("conversation-card-title");
|
||||
|
||||
await user.clear(title);
|
||||
await user.type(title, "Conversation 1 Renamed");
|
||||
await user.tab();
|
||||
|
||||
// Ensure the conversation is renamed
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
|
||||
name: "Conversation 1 Renamed",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not rename a conversation when the name is unchanged", async () => {
|
||||
const updateUserConversationSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"updateUserConversation",
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
const title = within(cards[0]).getByTestId("conversation-card-title");
|
||||
|
||||
await user.click(title);
|
||||
await user.tab();
|
||||
|
||||
// Ensure the conversation is not renamed
|
||||
expect(updateUserConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(title, "Conversation 1");
|
||||
await user.click(title);
|
||||
await user.tab();
|
||||
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
await user.click(title);
|
||||
await user.tab();
|
||||
|
||||
expect(updateUserConversationSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call onClose after clicking a card", async () => {
|
||||
renderConversationPanel();
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
const firstCard = cards[0];
|
||||
|
||||
await userEvent.click(firstCard);
|
||||
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
describe("New Conversation Button", () => {
|
||||
it("should display a confirmation modal when clicking", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("confirm-new-conversation-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const modal = screen.getByTestId("confirm-new-conversation-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call endSession and close panel after confirming", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const confirmButton = screen.getByText("Confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(endSessionMock).toHaveBeenCalledOnce();
|
||||
expect(onCloseMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should close the modal when cancelling", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderConversationPanel();
|
||||
|
||||
const newProjectButton = screen.getByTestId("new-conversation-button");
|
||||
await user.click(newProjectButton);
|
||||
|
||||
const cancelButton = screen.getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByTestId("confirm-new-conversation-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,46 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
const renderSidebar = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
path: "/conversation/:conversationId",
|
||||
Component: Sidebar,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
};
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should have the conversation panel open by default",
|
||||
() => {
|
||||
renderSidebar();
|
||||
expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should toggle the conversation panel",
|
||||
async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const projectPanelButton = screen.getByTestId(
|
||||
"toggle-conversation-panel",
|
||||
);
|
||||
|
||||
await user.click(projectPanelButton);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("conversation-panel"),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -52,10 +52,14 @@ describe("BaseModal", () => {
|
||||
expect(screen.getByText("Save")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cancel")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
});
|
||||
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await userEvent.click(screen.getByText("Cancel"));
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText("Cancel"));
|
||||
});
|
||||
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -76,7 +80,9 @@ describe("BaseModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText("Save"));
|
||||
});
|
||||
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
{ Component: App, path: "/conversation/:conversationId" },
|
||||
]);
|
||||
|
||||
const { endSessionMock } = vi.hoisted(() => ({
|
||||
endSessionMock: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("#/hooks/use-end-session", () => ({
|
||||
useEndSession: vi.fn(() => endSessionMock),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-terminal", () => ({
|
||||
useTerminal: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", async () => {
|
||||
renderWithProviders(<RouteStub initialEntries={["/conversation/123"]} />);
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it.skipIf(!MULTI_CONVO_UI_IS_ENABLED)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
|
||||
getConversationSpy.mockResolvedValue(null);
|
||||
renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endSessionMock).toHaveBeenCalledOnce();
|
||||
expect(errorToastSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("should not call endSession if the user has permission", async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "9999",
|
||||
lastUpdated: "",
|
||||
name: "",
|
||||
repo: "",
|
||||
state: "cold",
|
||||
});
|
||||
const { rerender } = renderWithProviders(
|
||||
<RouteStub initialEntries={["/conversation/9999"]} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(errorToastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
rerender(<RouteStub initialEntries={["/conversation"]} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(endSessionMock).not.toHaveBeenCalled();
|
||||
expect(errorToastSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+12
-77
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.18.0",
|
||||
"version": "0.17.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.18.0",
|
||||
"version": "0.17.0",
|
||||
"dependencies": {
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@nextui-org/react": "^2.6.10",
|
||||
@@ -48,7 +48,6 @@
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
@@ -1627,21 +1626,6 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mswjs/socket.io-binding": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@mswjs/socket.io-binding/-/socket.io-binding-0.1.1.tgz",
|
||||
"integrity": "sha512-mtFDHC5XMeti43toe3HBynD4uBxvUA2GfJVC6TDfhOQlH+G2hf5znNTSa75A30XdWL0P6aNqUKpcNo6L0Wop+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mswjs/interceptors": "^0.37.1",
|
||||
"engine.io-parser": "^5.2.3",
|
||||
"socket.io-parser": "^4.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mswjs/interceptors": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@nextui-org/accordion": {
|
||||
"version": "2.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@nextui-org/accordion/-/accordion-2.2.6.tgz",
|
||||
@@ -5374,7 +5358,6 @@
|
||||
"version": "5.62.11",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.11.tgz",
|
||||
"integrity": "sha512-Xb1nw0cYMdtFmwkvH9+y5yYFhXvLRCnXoqlzSw7UkqtCVFq3cG8q+rHZ2Yz1XrC+/ysUaTqbLKJqk95mCgC1oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.62.9"
|
||||
},
|
||||
@@ -8150,9 +8133,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.23.8",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.8.tgz",
|
||||
"integrity": "sha512-lfab8IzDn6EpI1ibZakcgS6WsfEBiB+43cuJo+wgylx1xKXf+Sp+YR3vFuQwC/u3sxYwV8Cxe3B0DpVUu/WiJQ==",
|
||||
"version": "1.23.7",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz",
|
||||
"integrity": "sha512-OygGC8kIcDhXX+6yAZRGLqwi2CmEXCbLQixeGUgYeR+Qwlppqmo7DIDr8XibtEBZp+fJcoYpoatp5qwLMEdcqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8191,10 +8174,8 @@
|
||||
"object-inspect": "^1.13.3",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.7",
|
||||
"own-keys": "^1.0.0",
|
||||
"regexp.prototype.flags": "^1.5.3",
|
||||
"safe-array-concat": "^1.1.3",
|
||||
"safe-push-apply": "^1.0.0",
|
||||
"safe-regex-test": "^1.1.0",
|
||||
"string.prototype.trim": "^1.2.10",
|
||||
"string.prototype.trimend": "^1.0.9",
|
||||
@@ -11211,7 +11192,6 @@
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.3.0.tgz",
|
||||
"integrity": "sha512-vHFahytLoF2enJklgtOtCtIjZrKD/LoxlaUusd5nh7dWv/dkKQJY74ndFSzxCdv7g0ueGg1ORgTSt4Y9LPZn9A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "~5.4.1",
|
||||
"commander": "~12.1.0",
|
||||
@@ -11239,7 +11219,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz",
|
||||
"integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.17.0 || ^14.13 || >=16.0.0"
|
||||
},
|
||||
@@ -13298,24 +13277,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/own-keys": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||
"integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"object-keys": "^1.1.1",
|
||||
"safe-push-apply": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
@@ -13599,14 +13560,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pkg-types": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.0.tgz",
|
||||
"integrity": "sha512-kS7yWjVFCkIw9hqdJBoMxDdzEngmkr5FXeWZZfQ6GoYacjVnsW6l2CcYW/0ThD0vF4LPJgVYnrg4d0uuhwYQbg==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.2.1.tgz",
|
||||
"integrity": "sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"confbox": "^0.1.8",
|
||||
"mlly": "^1.7.3",
|
||||
"mlly": "^1.7.2",
|
||||
"pathe": "^1.1.2"
|
||||
}
|
||||
},
|
||||
@@ -13813,7 +13774,6 @@
|
||||
"version": "1.203.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.203.2.tgz",
|
||||
"integrity": "sha512-3aLpEhM4i9sQQtobRmDttJ3rTW1+gwQ9HL7QiOeDueE2T7CguYibYS7weY1UhXMerx5lh1A7+szlOJTTibifLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -13828,9 +13788,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.25.4",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.25.4.tgz",
|
||||
"integrity": "sha512-jLdZDb+Q+odkHJ+MpW/9U5cODzqnB+fy2EiHSZES7ldV5LK7yjlVzTp7R8Xy6W6y75kfK8iWYtFVH7lvjwrCMA==",
|
||||
"version": "10.25.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.25.3.tgz",
|
||||
"integrity": "sha512-dzQmIFtM970z+fP9ziQ3yG4e3ULIbwZzJ734vaMVUTaKQ2+Ru1Ou/gjshOYVHCcd1rpAelC6ngjvjDXph98unQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -14163,7 +14123,6 @@
|
||||
"version": "15.4.0",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.4.0.tgz",
|
||||
"integrity": "sha512-Py6UkX3zV08RTvL6ZANRoBh9sL/ne6rQq79XlkHEdd82cZr2H9usbWpUNVadJntIZP2pu3M2rL1CN+5rQYfYFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.25.0",
|
||||
"html-parse-stringify": "^3.0.1"
|
||||
@@ -14931,30 +14890,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-push-apply": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
|
||||
"integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"isarray": "^2.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-push-apply/node_modules/isarray": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
|
||||
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex-test": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.18.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -75,7 +75,6 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@react-router/dev": "^7.1.1",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
|
||||
@@ -8,10 +8,8 @@ import {
|
||||
GetConfigResponse,
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings } from "#/services/settings";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -221,52 +219,6 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<Conversation[]>("/api/conversations");
|
||||
return data;
|
||||
}
|
||||
|
||||
static async deleteUserConversation(conversationId: string): Promise<void> {
|
||||
await openHands.delete(`/api/conversations/${conversationId}`);
|
||||
}
|
||||
|
||||
static async updateUserConversation(
|
||||
conversationId: string,
|
||||
conversation: Partial<Omit<Conversation, "id">>,
|
||||
): Promise<void> {
|
||||
await openHands.put(`/api/conversations/${conversationId}`, conversation);
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
githubToken?: string,
|
||||
selectedRepository?: string,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
github_token: githubToken,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
"/api/conversations",
|
||||
body,
|
||||
);
|
||||
|
||||
// TODO: remove this once we have a multi-conversation UI
|
||||
localStorage.setItem("latest_conversation_id", data.conversation_id);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getConversation(
|
||||
conversationId: string,
|
||||
): Promise<Conversation | null> {
|
||||
const { data } = await openHands.get<Conversation | null>(
|
||||
`/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchEvents(
|
||||
conversationId: string,
|
||||
params: {
|
||||
@@ -296,22 +248,20 @@ class OpenHands {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
static async getSettings(): Promise<ApiSettings> {
|
||||
const { data } = await openHands.get<ApiSettings>("/api/settings");
|
||||
static async newConversation(params: {
|
||||
githubToken?: string;
|
||||
selectedRepository?: string;
|
||||
}): Promise<{ conversation_id: string }> {
|
||||
const { data } = await openHands.post<{
|
||||
conversation_id: string;
|
||||
}>("/api/conversations", {
|
||||
github_token: params.githubToken,
|
||||
selected_repository: params.selectedRepository,
|
||||
});
|
||||
// TODO: remove this once we have a multi-conversation UI
|
||||
localStorage.setItem("latest_conversation_id", data.conversation_id);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the settings to the server. Only valid settings are saved.
|
||||
* @param settings - the settings to save
|
||||
*/
|
||||
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ProjectState } from "#/components/features/conversation-panel/conversation-state-indicator";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
@@ -59,11 +57,3 @@ export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string;
|
||||
state: ProjectState;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuListItemProps {
|
||||
testId?: string;
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ContextMenuListItem({
|
||||
children,
|
||||
testId,
|
||||
onClick,
|
||||
isDisabled,
|
||||
}: React.PropsWithChildren<ContextMenuListItemProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId || "context-menu-list-item"}
|
||||
data-testid="context-menu-list-item"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuProps {
|
||||
ref?: React.RefObject<HTMLUListElement | null>;
|
||||
ref: React.RefObject<HTMLUListElement | null>;
|
||||
testId?: string;
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLUListElement>["className"];
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmDeleteModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title="Are you sure you want to delete this project?" />
|
||||
<BaseModalDescription description="All data associated with this project will be lost." />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-[#4465DB]"
|
||||
text="Confirm"
|
||||
/>
|
||||
<ModalButton onClick={onCancel} className="bg-danger" text="Cancel" />
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import React from "react";
|
||||
import { formatTimeDelta } from "#/utils/format-time-delta";
|
||||
import { ConversationRepoLink } from "./conversation-repo-link";
|
||||
import {
|
||||
ProjectState,
|
||||
ConversationStateIndicator,
|
||||
} from "./conversation-state-indicator";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { EllipsisButton } from "./ellipsis-button";
|
||||
|
||||
interface ProjectCardProps {
|
||||
onClick: () => void;
|
||||
onDelete: () => void;
|
||||
onChangeTitle: (title: string) => void;
|
||||
name: string;
|
||||
repo: string | null;
|
||||
lastUpdated: string; // ISO 8601
|
||||
state?: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onChangeTitle,
|
||||
name,
|
||||
repo,
|
||||
lastUpdated,
|
||||
state = "cold",
|
||||
}: ProjectCardProps) {
|
||||
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (inputRef.current?.value) {
|
||||
const trimmed = inputRef.current.value.trim();
|
||||
onChangeTitle(trimmed);
|
||||
inputRef.current!.value = trimmed;
|
||||
} else {
|
||||
// reset the value if it's empty
|
||||
inputRef.current!.value = name;
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputClick = (event: React.MouseEvent<HTMLInputElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDelete = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className="h-[100px] w-full px-[18px] py-4 border-b border-neutral-600"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-testid="conversation-card-title"
|
||||
onClick={handleInputClick}
|
||||
onBlur={handleBlur}
|
||||
type="text"
|
||||
defaultValue={name}
|
||||
className="text-sm leading-6 font-semibold bg-transparent"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 relative">
|
||||
<ConversationStateIndicator state={state} />
|
||||
<EllipsisButton
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setContextMenuVisible((prev) => !prev);
|
||||
}}
|
||||
/>
|
||||
{contextMenuVisible && (
|
||||
<ContextMenu testId="context-menu" className="absolute left-full">
|
||||
<ContextMenuListItem
|
||||
testId="delete-button"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
Delete
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repo && (
|
||||
<ConversationRepoLink
|
||||
repo={repo}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<time>{formatTimeDelta(new Date(lastUpdated))} ago</time>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import React from "react";
|
||||
import { useLocation, useNavigate, useParams } from "react-router";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
import { NewConversationButton } from "./new-conversation-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { ExitConversationModal } from "./exit-conversation-modal";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { conversationId: cid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const endSession = useEndSession();
|
||||
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [
|
||||
confirmExitConversationModalVisible,
|
||||
setConfirmExitConversationModalVisible,
|
||||
] = React.useState(false);
|
||||
const [selectedConversationId, setSelectedConversationId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedConversationId) {
|
||||
deleteConversation({ conversationId: selectedConversationId });
|
||||
setConfirmDeleteModalVisible(false);
|
||||
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeTitle = (
|
||||
conversationId: string,
|
||||
oldTitle: string,
|
||||
newTitle: string,
|
||||
) => {
|
||||
if (oldTitle !== newTitle)
|
||||
updateConversation({
|
||||
id: conversationId,
|
||||
conversation: { name: newTitle },
|
||||
});
|
||||
};
|
||||
|
||||
const handleClickCard = (conversationId: string) => {
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-panel"
|
||||
className="w-[350px] h-full border border-neutral-700 bg-neutral-800 rounded-xl"
|
||||
>
|
||||
<div className="pt-4 px-4 flex items-center justify-between">
|
||||
{location.pathname.startsWith("/conversation") && (
|
||||
<NewConversationButton
|
||||
onClick={() => setConfirmExitConversationModalVisible(true)}
|
||||
/>
|
||||
)}
|
||||
{isFetching && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-danger">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-400">No conversations found</p>
|
||||
</div>
|
||||
)}
|
||||
{conversations?.map((project) => (
|
||||
<ConversationCard
|
||||
key={project.conversation_id}
|
||||
onClick={() => handleClickCard(project.conversation_id)}
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onChangeTitle={(title) =>
|
||||
handleChangeTitle(project.conversation_id, project.name, title)
|
||||
}
|
||||
name={project.name}
|
||||
repo={project.repo}
|
||||
lastUpdated={project.lastUpdated}
|
||||
state={project.state}
|
||||
/>
|
||||
))}
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setConfirmDeleteModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmExitConversationModalVisible && (
|
||||
<ExitConversationModal
|
||||
onConfirm={() => {
|
||||
endSession();
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => setConfirmExitConversationModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
interface ConversationRepoLinkProps {
|
||||
repo: string;
|
||||
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
repo,
|
||||
onClick,
|
||||
}: ConversationRepoLinkProps) {
|
||||
return (
|
||||
<a
|
||||
data-testid="conversation-card-repo"
|
||||
href={`https://github.com/${repo}`}
|
||||
target="_blank noopener noreferrer"
|
||||
onClick={onClick}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
{repo}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import ColdIcon from "./state-indicators/cold.svg?react";
|
||||
import CoolingIcon from "./state-indicators/cooling.svg?react";
|
||||
import FinishedIcon from "./state-indicators/finished.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import WaitingIcon from "./state-indicators/waiting.svg?react";
|
||||
import WarmIcon from "./state-indicators/warm.svg?react";
|
||||
|
||||
type SVGIcon = React.FunctionComponent<React.SVGProps<SVGSVGElement>>;
|
||||
export type ProjectState =
|
||||
| "cold"
|
||||
| "cooling"
|
||||
| "finished"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "warm";
|
||||
|
||||
const INDICATORS: Record<ProjectState, SVGIcon> = {
|
||||
cold: ColdIcon,
|
||||
cooling: CoolingIcon,
|
||||
finished: FinishedIcon,
|
||||
running: RunningIcon,
|
||||
waiting: WaitingIcon,
|
||||
warm: WarmIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
state: ProjectState;
|
||||
}
|
||||
|
||||
export function ConversationStateIndicator({
|
||||
state,
|
||||
}: ConversationStateIndicatorProps) {
|
||||
const StateIcon = INDICATORS[state];
|
||||
|
||||
return (
|
||||
<div data-testid={`${state}-indicator`}>
|
||||
<StateIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { FaEllipsisV } from "react-icons/fa";
|
||||
|
||||
interface EllipsisButtonProps {
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
|
||||
return (
|
||||
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
|
||||
<FaEllipsisV fill="#a3a3a3" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { ModalButton } from "#/components/shared/buttons/modal-button";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
|
||||
interface ExitConversationModalProps {
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExitConversationModal({
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ExitConversationModalProps) {
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody testID="confirm-new-conversation-modal">
|
||||
<BaseModalTitle title="Creating a new conversation will replace your active conversation" />
|
||||
<div className="flex w-full gap-2">
|
||||
<ModalButton
|
||||
text="Confirm"
|
||||
onClick={onConfirm}
|
||||
className="bg-[#C63143] flex-1"
|
||||
/>
|
||||
<ModalButton
|
||||
text="Cancel"
|
||||
onClick={onClose}
|
||||
className="bg-neutral-700 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
interface NewConversationButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function NewConversationButton({ onClick }: NewConversationButtonProps) {
|
||||
return (
|
||||
<button
|
||||
data-testid="new-conversation-button"
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="font-bold bg-[#4465DB] px-2 py-1 rounded"
|
||||
>
|
||||
+ New Project
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2C9.87012 1.44772 9.4224 1 8.87012 1C8.31783 1 7.87012 1.44772 7.87012 2V8C7.87012 8.55228 8.31783 9 8.87012 9C9.4224 9 9.87012 8.55228 9.87012 8V2Z" fill="#A7A9AC"/>
|
||||
<path d="M10.8698 2.42001V2.56001C10.8698 2.93001 11.0698 3.28001 11.4098 3.43001C13.6798 4.43001 15.2198 6.80001 14.9698 9.48001C14.6998 12.47 12.0998 14.87 9.08979 14.92C5.73979 14.97 2.98979 12.26 2.98979 8.92001C2.98979 6.57001 4.34979 4.54001 6.30979 3.56001C6.63979 3.40001 6.85979 3.08001 6.85979 2.72001V2.55001C6.85979 1.86001 6.13979 1.43001 5.50979 1.73001C2.43979 3.20001 0.449793 6.62001 1.13979 10.41C1.70979 13.57 4.23979 16.14 7.38979 16.76C12.5098 17.76 16.9998 13.86 16.9998 8.92001C16.9998 5.61001 14.9898 2.78001 12.1198 1.56001C11.5298 1.31001 10.8698 1.78001 10.8698 2.42001Z" fill="#A7A9AC"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 904 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2.02002C9.87012 1.46773 9.4224 1.02002 8.87012 1.02002C8.31783 1.02002 7.87012 1.46773 7.87012 2.02002V8.02002C7.87012 8.5723 8.31783 9.02002 8.87012 9.02002C9.4224 9.02002 9.87012 8.5723 9.87012 8.02002V2.02002Z" fill="#EFC818"/>
|
||||
<path d="M10.8698 2.44003V2.58003C10.8698 2.95003 11.0698 3.30003 11.4098 3.45003C13.6798 4.45003 15.2198 6.82003 14.9698 9.50003C14.6998 12.49 12.0998 14.89 9.08979 14.94C5.73979 14.99 2.98979 12.28 2.98979 8.94003C2.98979 6.59003 4.34979 4.56003 6.30979 3.58003C6.63979 3.42003 6.85979 3.10003 6.85979 2.74003V2.57003C6.85979 1.88003 6.13979 1.45003 5.50979 1.75003C2.43979 3.23003 0.449793 6.64003 1.13979 10.43C1.70979 13.59 4.23979 16.16 7.38979 16.78C12.5098 17.78 16.9998 13.88 16.9998 8.94003C16.9998 5.63003 14.9898 2.80003 12.1198 1.58003C11.5298 1.33003 10.8698 1.80003 10.8698 2.44003Z" fill="#EFC818"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 968 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 16.8599C13.4183 16.8599 17 13.2781 17 8.85986C17 4.44159 13.4183 0.859863 9 0.859863C4.58172 0.859863 1 4.44159 1 8.85986C1 13.2781 4.58172 16.8599 9 16.8599Z" fill="#779FD4"/>
|
||||
<path d="M4.61035 8.43014L7.86035 12.0301L13.3904 6.64014" stroke="#231F20" stroke-width="2" stroke-miterlimit="10" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 433 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.04004 3.10986C12.06 3.10986 14.57 5.34986 14.98 8.25986C15.05 8.74986 15.47 9.10986 15.96 9.10986C16.57 9.10986 17.04 8.56986 16.96 7.96986C16.41 4.08986 13.07 1.10986 9.04004 1.10986C4.62004 1.10986 1.04004 4.68986 1.04004 9.10986C1.04004 13.1399 4.02004 16.4799 7.90004 17.0299C8.50004 17.1199 9.04004 16.6399 9.04004 16.0299C9.04004 15.5399 8.68004 15.1199 8.19004 15.0499C5.28004 14.6399 3.04004 12.1299 3.04004 9.10986C3.04004 5.79986 5.73004 3.10986 9.04004 3.10986Z" fill="#60BB46"/>
|
||||
<path d="M12.3504 9.11L7.40039 6.25V11.96L12.3504 9.11Z" fill="#60BB46"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 680 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.76039 6.99002C8.478 6.99002 9.87039 5.59763 9.87039 3.88002C9.87039 2.16241 8.478 0.77002 6.76039 0.77002C5.04279 0.77002 3.65039 2.16241 3.65039 3.88002C3.65039 5.59763 5.04279 6.99002 6.76039 6.99002Z" fill="#FFE165"/>
|
||||
<path d="M1.0802 17.0799C1.0802 17.0799 0.610196 11.5499 3.0102 9.67992C4.7902 8.29992 7.3302 9.44992 9.7802 7.95992C11.5802 6.86992 13.6102 4.10992 14.5202 2.49992C14.9302 1.77992 15.9102 1.62992 16.6102 2.05992C17.3802 2.51992 17.6102 3.53992 17.1102 4.28992C16.2302 5.58992 14.1802 8.85992 13.1202 10.3699C10.7602 13.7599 11.4302 17.0799 11.4302 17.0799H1.0702H1.0802Z" fill="#FFE165"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 726 B |
@@ -1,4 +0,0 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2.08984C9.87012 1.53756 9.4224 1.08984 8.87012 1.08984C8.31783 1.08984 7.87012 1.53756 7.87012 2.08984V8.08984C7.87012 8.64213 8.31783 9.08984 8.87012 9.08984C9.4224 9.08984 9.87012 8.64213 9.87012 8.08984V2.08984Z" fill="#60BB46"/>
|
||||
<path d="M10.8702 2.50988V2.64988C10.8702 3.01988 11.0702 3.36988 11.4102 3.51988C13.6802 4.51988 15.2202 6.88988 14.9702 9.56988C14.7002 12.5599 12.1002 14.9599 9.09021 15.0099C5.74021 15.0599 2.99021 12.3499 2.99021 9.00988C2.99021 6.65988 4.35021 4.62988 6.31021 3.64988C6.64021 3.48988 6.86021 3.16988 6.86021 2.80988V2.63988C6.86021 1.94988 6.14021 1.51988 5.51021 1.81988C2.42021 3.30988 0.430214 6.71988 1.12021 10.5199C1.69021 13.6799 4.22021 16.2499 7.37021 16.8699C12.4902 17.8699 16.9802 13.9699 16.9802 9.02988C16.9802 5.71988 14.9702 2.88988 12.1002 1.66988C11.5102 1.41988 10.8502 1.88988 10.8502 2.52988L10.8702 2.50988Z" fill="#60BB46"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1008 B |
@@ -31,6 +31,17 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
}
|
||||
};
|
||||
|
||||
if (isGitHubErrorReponse(repositories)) {
|
||||
return (
|
||||
<SuggestionBox
|
||||
title="Error Fetching Repositories"
|
||||
content={
|
||||
<p className="text-danger text-center">{repositories.message}</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isLoggedIn = !!user && !isGitHubErrorReponse(user);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import FolderIcon from "#/icons/docs.svg?react";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
import { DocsButton } from "#/components/shared/buttons/docs-button";
|
||||
@@ -13,28 +12,20 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { ExitProjectConfirmationModal } from "#/components/shared/modals/exit-project-confirmation-modal";
|
||||
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ConversationPanel } from "../conversation-panel/conversation-panel";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
|
||||
const user = useGitHubUser();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
const { logout } = useAuth();
|
||||
const { data: settings, isError: settingsIsError } = useSettings();
|
||||
const { isUpToDate: settingsAreUpToDate } = useSettingsUpToDate();
|
||||
const { settingsAreUpToDate, settings } = useSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
const [startNewProjectModalIsOpen, setStartNewProjectModalIsOpen] =
|
||||
React.useState(false);
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] = React.useState(
|
||||
MULTI_CONVO_UI_IS_ENABLED,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
@@ -43,6 +34,12 @@ export function Sidebar() {
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settings || !settingsAreUpToDate) {
|
||||
setSettingsModalIsOpen(true);
|
||||
}
|
||||
}, [settings, settingsAreUpToDate]);
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
// If the user closes the modal without connecting to GitHub,
|
||||
// we need to log them out to clear the invalid token from the
|
||||
@@ -56,12 +53,9 @@ export function Sidebar() {
|
||||
setStartNewProjectModalIsOpen(true);
|
||||
};
|
||||
|
||||
const showSettingsModal =
|
||||
isAuthed && (!settingsAreUpToDate || settingsModalIsOpen);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1 relative">
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleClickLogo} />
|
||||
@@ -77,45 +71,18 @@ export function Sidebar() {
|
||||
/>
|
||||
)}
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{MULTI_CONVO_UI_IS_ENABLED && (
|
||||
<button
|
||||
data-testid="toggle-conversation-panel"
|
||||
type="button"
|
||||
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
|
||||
className={cn(
|
||||
conversationPanelIsOpen ? "border-b-2 border-[#FFE165]" : "",
|
||||
)}
|
||||
>
|
||||
<FolderIcon width={28} height={28} />
|
||||
</button>
|
||||
)}
|
||||
<DocsButton />
|
||||
<ExitProjectButton
|
||||
onClick={() => setStartNewProjectModalIsOpen(true)}
|
||||
/>
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
<div
|
||||
className="absolute h-full left-[calc(100%+12px)] top-0 z-20" // 12px padding (sidebar parent)
|
||||
>
|
||||
<ConversationPanel
|
||||
onClose={() => setConversationPanelIsOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{settingsIsError ||
|
||||
(showSettingsModal && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
/>
|
||||
))}
|
||||
{!accountSettingsModalOpen && settingsModalIsOpen && (
|
||||
<SettingsModal onClose={() => setSettingsModalIsOpen(false)} />
|
||||
)}
|
||||
{startNewProjectModalIsOpen && (
|
||||
<ExitProjectConfirmationModal
|
||||
onClose={() => setStartNewProjectModalIsOpen(false)}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import React, { CSSProperties, JSX, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
VscChevronDown,
|
||||
VscChevronLeft,
|
||||
VscChevronRight,
|
||||
VscChevronUp,
|
||||
} from "react-icons/vsc";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "../shared/buttons/icon-button";
|
||||
|
||||
export enum Orientation {
|
||||
HORIZONTAL = "horizontal",
|
||||
VERTICAL = "vertical",
|
||||
}
|
||||
|
||||
enum Collapse {
|
||||
COLLAPSED = "collapsed",
|
||||
SPLIT = "split",
|
||||
FILLED = "filled",
|
||||
}
|
||||
|
||||
type ResizablePanelProps = {
|
||||
firstChild: React.ReactNode;
|
||||
firstClassName: string | undefined;
|
||||
secondChild: React.ReactNode;
|
||||
secondClassName: string | undefined;
|
||||
className: string | undefined;
|
||||
orientation: Orientation;
|
||||
initialSize: number;
|
||||
};
|
||||
|
||||
export function ResizablePanel({
|
||||
firstChild,
|
||||
firstClassName,
|
||||
secondChild,
|
||||
secondClassName,
|
||||
className,
|
||||
orientation,
|
||||
initialSize,
|
||||
}: ResizablePanelProps): JSX.Element {
|
||||
const [firstSize, setFirstSize] = useState<number>(initialSize);
|
||||
const [dividerPosition, setDividerPosition] = useState<number | null>(null);
|
||||
const firstRef = useRef<HTMLDivElement>(null);
|
||||
const secondRef = useRef<HTMLDivElement>(null);
|
||||
const [collapse, setCollapse] = useState<Collapse>(Collapse.SPLIT);
|
||||
const isHorizontal = orientation === Orientation.HORIZONTAL;
|
||||
|
||||
useEffect(() => {
|
||||
if (dividerPosition == null || !firstRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
const getFirstSizeFromEvent = (e: MouseEvent) => {
|
||||
const position = isHorizontal ? e.clientX : e.clientY;
|
||||
return firstSize + position - dividerPosition;
|
||||
};
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const newFirstSize = `${getFirstSizeFromEvent(e)}px`;
|
||||
const { current } = firstRef;
|
||||
if (current) {
|
||||
if (isHorizontal) {
|
||||
current.style.width = newFirstSize;
|
||||
current.style.minWidth = newFirstSize;
|
||||
} else {
|
||||
current.style.height = newFirstSize;
|
||||
current.style.minHeight = newFirstSize;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onMouseUp = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (firstRef.current) {
|
||||
firstRef.current.style.transition = "";
|
||||
}
|
||||
if (secondRef.current) {
|
||||
secondRef.current.style.transition = "";
|
||||
}
|
||||
setFirstSize(getFirstSizeFromEvent(e));
|
||||
setDividerPosition(null);
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
}, [dividerPosition, firstSize, orientation]);
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (firstRef.current) {
|
||||
firstRef.current.style.transition = "none";
|
||||
}
|
||||
if (secondRef.current) {
|
||||
secondRef.current.style.transition = "none";
|
||||
}
|
||||
const position = isHorizontal ? e.clientX : e.clientY;
|
||||
setDividerPosition(position);
|
||||
};
|
||||
|
||||
const getStyleForFirst = () => {
|
||||
const style: CSSProperties = { overflow: "hidden" };
|
||||
if (collapse === Collapse.COLLAPSED) {
|
||||
style.opacity = 0;
|
||||
style.width = 0;
|
||||
style.minWidth = 0;
|
||||
style.height = 0;
|
||||
style.minHeight = 0;
|
||||
} else if (collapse === Collapse.SPLIT) {
|
||||
const firstSizePx = `${firstSize}px`;
|
||||
if (isHorizontal) {
|
||||
style.width = firstSizePx;
|
||||
style.minWidth = firstSizePx;
|
||||
} else {
|
||||
style.height = firstSizePx;
|
||||
style.minHeight = firstSizePx;
|
||||
}
|
||||
} else {
|
||||
style.flexGrow = 1;
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
const getStyleForSecond = () => {
|
||||
const style: CSSProperties = { overflow: "hidden" };
|
||||
if (collapse === Collapse.FILLED) {
|
||||
style.opacity = 0;
|
||||
style.width = 0;
|
||||
style.minWidth = 0;
|
||||
style.height = 0;
|
||||
style.minHeight = 0;
|
||||
} else if (collapse === Collapse.SPLIT) {
|
||||
style.flexGrow = 1;
|
||||
} else {
|
||||
style.flexGrow = 1;
|
||||
}
|
||||
return style;
|
||||
};
|
||||
|
||||
const onCollapse = () => {
|
||||
if (collapse === Collapse.SPLIT) {
|
||||
setCollapse(Collapse.COLLAPSED);
|
||||
} else {
|
||||
setCollapse(Collapse.SPLIT);
|
||||
}
|
||||
};
|
||||
|
||||
const onExpand = () => {
|
||||
if (collapse === Collapse.SPLIT) {
|
||||
setCollapse(Collapse.FILLED);
|
||||
} else {
|
||||
setCollapse(Collapse.SPLIT);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={twMerge("flex", !isHorizontal && "flex-col", className)}>
|
||||
<div
|
||||
ref={firstRef}
|
||||
className={twMerge(firstClassName, "transition-all ease-soft-spring")}
|
||||
style={getStyleForFirst()}
|
||||
>
|
||||
{firstChild}
|
||||
</div>
|
||||
<div
|
||||
className={`${isHorizontal ? "cursor-ew-resize w-3 flex-col" : "cursor-ns-resize h-3 flex-row"} shrink-0 flex justify-center items-center`}
|
||||
onMouseDown={collapse === Collapse.SPLIT ? onMouseDown : undefined}
|
||||
>
|
||||
<IconButton
|
||||
icon={isHorizontal ? <VscChevronLeft /> : <VscChevronUp />}
|
||||
ariaLabel="Collapse"
|
||||
onClick={onCollapse}
|
||||
/>
|
||||
<IconButton
|
||||
icon={isHorizontal ? <VscChevronRight /> : <VscChevronDown />}
|
||||
ariaLabel="Expand"
|
||||
onClick={onExpand}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={secondRef}
|
||||
className={twMerge(secondClassName, "transition-all ease-soft-spring")}
|
||||
style={getStyleForSecond()}
|
||||
>
|
||||
{secondChild}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Button } from "@nextui-org/react";
|
||||
import React, { ReactElement } from "react";
|
||||
import React, { MouseEventHandler, ReactElement } from "react";
|
||||
|
||||
export interface IconButtonProps {
|
||||
icon: ReactElement;
|
||||
onClick: () => void;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
ariaLabel: string;
|
||||
testId?: string;
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function IconButton({
|
||||
<Button
|
||||
type="button"
|
||||
variant="flat"
|
||||
onPress={onClick}
|
||||
onClick={onClick}
|
||||
className="cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[20px] h-[20px]"
|
||||
aria-label={ariaLabel}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -7,12 +7,7 @@ interface SettingsButtonProps {
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="settings-button"
|
||||
tooltip="Settings"
|
||||
ariaLabel="Settings"
|
||||
onClick={onClick}
|
||||
>
|
||||
<TooltipButton tooltip="Settings" ariaLabel="Settings" onClick={onClick}>
|
||||
<CogTooth />
|
||||
</TooltipButton>
|
||||
);
|
||||
|
||||
@@ -1,37 +1,27 @@
|
||||
import { Input, Tooltip } from "@nextui-org/react";
|
||||
import { Input } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface APIKeyInputProps {
|
||||
isDisabled: boolean;
|
||||
isSet: boolean;
|
||||
defaultValue: string;
|
||||
}
|
||||
|
||||
export function APIKeyInput({ isDisabled, isSet }: APIKeyInputProps) {
|
||||
export function APIKeyInput({ isDisabled, defaultValue }: APIKeyInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<fieldset data-testid="api-key-input" className="flex flex-col gap-2">
|
||||
<Tooltip content={isSet ? "API Key is set" : "API Key is not set"}>
|
||||
<label
|
||||
htmlFor="api-key"
|
||||
className="font-[500] text-[#A3A3A3] text-xs flex items-center gap-1 self-start"
|
||||
>
|
||||
{isSet && <FaCheckCircle className="text-[#00D1B2] inline-block" />}
|
||||
{!isSet && (
|
||||
<FaExclamationCircle className="text-[#FF3860] inline-block" />
|
||||
)}
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
</label>
|
||||
</Tooltip>
|
||||
<label htmlFor="api-key" className="font-[500] text-[#A3A3A3] text-xs">
|
||||
{t(I18nKey.SETTINGS_FORM$API_KEY_LABEL)}
|
||||
</label>
|
||||
<Input
|
||||
isDisabled={isDisabled}
|
||||
id="api-key"
|
||||
name="api-key"
|
||||
aria-label="API Key"
|
||||
type="password"
|
||||
defaultValue=""
|
||||
defaultValue={defaultValue}
|
||||
classNames={{
|
||||
inputWrapper: "bg-[#27272A] rounded-md text-sm px-3 py-[10px]",
|
||||
}}
|
||||
|
||||
@@ -8,12 +8,12 @@ import { ModalBody } from "../modal-body";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { CustomInput } from "../../custom-input";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
@@ -30,7 +30,7 @@ export function AccountSettingsForm({
|
||||
}: AccountSettingsFormProps) {
|
||||
const { gitHubToken, setGitHubToken, logout } = useAuth();
|
||||
const { data: config } = useConfig();
|
||||
const { mutate: saveSettings } = useSaveSettings();
|
||||
const { saveSettings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { AccountSettingsForm } from "./account-settings-form";
|
||||
|
||||
@@ -9,7 +9,7 @@ interface AccountSettingsModalProps {
|
||||
|
||||
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
|
||||
const user = useGitHubUser();
|
||||
const { data: settings } = useSettings();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// FIXME: Bad practice to use localStorage directly
|
||||
const analyticsConsent = localStorage.getItem("analytics-consent");
|
||||
|
||||
@@ -23,7 +23,7 @@ export function FooterContent({ actions, closeModal }: FooterContentProps) {
|
||||
key={label}
|
||||
type="button"
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
onClick={() => {
|
||||
action();
|
||||
if (closeAfterAction) closeModal();
|
||||
}}
|
||||
|
||||
@@ -35,20 +35,14 @@ export function BaseModalDescription({
|
||||
}
|
||||
|
||||
interface BaseModalProps {
|
||||
testId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
buttons: ButtonConfig[];
|
||||
}
|
||||
|
||||
export function BaseModal({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
buttons,
|
||||
}: BaseModalProps) {
|
||||
export function BaseModal({ title, description, buttons }: BaseModalProps) {
|
||||
return (
|
||||
<ModalBody testID={testId}>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-2 self-start">
|
||||
<BaseModalTitle title={title} />
|
||||
<BaseModalDescription description={description} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BaseModal } from "./base-modal";
|
||||
|
||||
interface DangerModalProps {
|
||||
testId?: string;
|
||||
|
||||
title: string;
|
||||
description: string;
|
||||
|
||||
@@ -12,15 +10,9 @@ interface DangerModalProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function DangerModal({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
buttons,
|
||||
}: DangerModalProps) {
|
||||
export function DangerModal({ title, description, buttons }: DangerModalProps) {
|
||||
return (
|
||||
<BaseModal
|
||||
testId={testId}
|
||||
title={title}
|
||||
description={description}
|
||||
buttons={[
|
||||
|
||||
@@ -127,7 +127,7 @@ function SecurityInvariant() {
|
||||
<>
|
||||
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
|
||||
<Button onPress={() => exportTraces()} className="bg-neutral-700">
|
||||
<Button onClick={() => exportTraces()} className="bg-neutral-700">
|
||||
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -162,7 +162,7 @@ function SecurityInvariant() {
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
|
||||
<Button
|
||||
className="bg-neutral-700"
|
||||
onPress={() => updatePolicy({ policy })}
|
||||
onClick={() => updatePolicy({ policy })}
|
||||
>
|
||||
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
|
||||
</Button>
|
||||
@@ -184,7 +184,7 @@ function SecurityInvariant() {
|
||||
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
|
||||
<Button
|
||||
className="bg-neutral-700"
|
||||
onPress={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
|
||||
onClick={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
|
||||
>
|
||||
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
|
||||
</Button>
|
||||
|
||||
@@ -68,7 +68,6 @@ export function ModelSelector({
|
||||
LLM Provider
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-provider"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-provider"
|
||||
@@ -92,11 +91,7 @@ export function ModelSelector({
|
||||
{Object.keys(models)
|
||||
.filter((provider) => VERIFIED_PROVIDERS.includes(provider))
|
||||
.map((provider) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`provider-item-${provider}`}
|
||||
key={provider}
|
||||
value={provider}
|
||||
>
|
||||
<AutocompleteItem key={provider} value={provider}>
|
||||
{mapProvider(provider)}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
@@ -118,7 +113,6 @@ export function ModelSelector({
|
||||
LLM Model
|
||||
</label>
|
||||
<Autocomplete
|
||||
data-testid="llm-model"
|
||||
isRequired
|
||||
isVirtualized={false}
|
||||
name="llm-model"
|
||||
@@ -150,11 +144,7 @@ export function ModelSelector({
|
||||
{models[selectedProvider || ""]?.models
|
||||
.filter((model) => !VERIFIED_MODELS.includes(model))
|
||||
.map((model) => (
|
||||
<AutocompleteItem
|
||||
data-testid={`model-item-${model}`}
|
||||
key={model}
|
||||
value={model}
|
||||
>
|
||||
<AutocompleteItem key={model} value={model}>
|
||||
{model}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { extractSettings, saveSettingsView } from "#/utils/settings-utils";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { AdvancedOptionSwitch } from "../../inputs/advanced-option-switch";
|
||||
import { AgentInput } from "../../inputs/agent-input";
|
||||
@@ -19,7 +20,6 @@ import { CustomModelInput } from "../../inputs/custom-model-input";
|
||||
import { SecurityAnalyzerInput } from "../../inputs/security-analyzers-input";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
|
||||
|
||||
interface SettingsFormProps {
|
||||
disabled?: boolean;
|
||||
@@ -38,7 +38,7 @@ export function SettingsForm({
|
||||
securityAnalyzers,
|
||||
onClose,
|
||||
}: SettingsFormProps) {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { saveSettings } = useSettings();
|
||||
const endSession = useEndSession();
|
||||
|
||||
const location = useLocation();
|
||||
@@ -82,6 +82,7 @@ export function SettingsForm({
|
||||
const resetOngoingSession = () => {
|
||||
if (location.pathname.startsWith("/conversations/")) {
|
||||
endSession();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,7 +92,7 @@ export function SettingsForm({
|
||||
const newSettings = extractSettings(formData);
|
||||
|
||||
saveSettingsView(isUsingAdvancedOptions ? "advanced" : "basic");
|
||||
await saveSettings(newSettings, { onSuccess: onClose });
|
||||
await saveSettings(newSettings);
|
||||
resetOngoingSession();
|
||||
|
||||
posthog.capture("settings_saved", {
|
||||
@@ -101,9 +102,11 @@ export function SettingsForm({
|
||||
};
|
||||
|
||||
const handleConfirmResetSettings = async () => {
|
||||
await saveSettings(getDefaultSettings(), { onSuccess: onClose });
|
||||
await saveSettings(getDefaultSettings());
|
||||
resetOngoingSession();
|
||||
posthog.capture("settings_reset");
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleConfirmEndSession = () => {
|
||||
@@ -119,6 +122,7 @@ export function SettingsForm({
|
||||
setConfirmEndSessionModalOpen(true);
|
||||
} else {
|
||||
handleFormSubmission(formData);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -161,7 +165,7 @@ export function SettingsForm({
|
||||
|
||||
<APIKeyInput
|
||||
isDisabled={!!disabled}
|
||||
isSet={settings.LLM_API_KEY === "SET"}
|
||||
defaultValue={settings.LLM_API_KEY || ""}
|
||||
/>
|
||||
|
||||
{showAdvancedOptions && (
|
||||
@@ -217,7 +221,6 @@ export function SettingsForm({
|
||||
{confirmResetDefaultsModalOpen && (
|
||||
<ModalBackdrop>
|
||||
<DangerModal
|
||||
testId="reset-defaults-modal"
|
||||
title={t(I18nKey.SETTINGS_FORM$ARE_YOU_SURE_LABEL)}
|
||||
description={t(
|
||||
I18nKey.SETTINGS_FORM$ALL_INFORMATION_WILL_BE_DELETED_MESSAGE,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { useAIConfigOptions } from "#/hooks/query/use-ai-config-options";
|
||||
import { Settings } from "#/services/settings";
|
||||
import { LoadingSpinner } from "../../loading-spinner";
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { SettingsForm } from "./settings-form";
|
||||
|
||||
interface SettingsModalProps {
|
||||
settings: Settings;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({ onClose, settings }: SettingsModalProps) {
|
||||
export function SettingsModal({ onClose }: SettingsModalProps) {
|
||||
const { settings } = useSettings();
|
||||
const aiConfigOptions = useAIConfigOptions();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
import React from "react";
|
||||
import { useNavigation } from "react-router";
|
||||
import { useNavigate, useNavigation } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { RootState } from "#/store";
|
||||
import { addFile, removeFile } from "#/state/initial-query-slice";
|
||||
import {
|
||||
addFile,
|
||||
removeFile,
|
||||
setInitialQuery,
|
||||
} from "#/state/initial-query-slice";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@@ -12,7 +21,6 @@ import { cn } from "#/utils/utils";
|
||||
import { AttachImageLabel } from "../features/images/attach-image-label";
|
||||
import { ImageCarousel } from "../features/images/image-carousel";
|
||||
import { UploadImageInput } from "../features/images/upload-image-input";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { LoadingSpinner } from "./loading-spinner";
|
||||
|
||||
interface TaskFormProps {
|
||||
@@ -22,6 +30,8 @@ interface TaskFormProps {
|
||||
export function TaskForm({ ref }: TaskFormProps) {
|
||||
const dispatch = useDispatch();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const { gitHubToken } = useAuth();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@@ -32,7 +42,24 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
getRandomKey(SUGGESTIONS["non-repo"]),
|
||||
);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const newConversationMutation = useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (variables.q) dispatch(setInitialQuery(variables.q));
|
||||
return OpenHands.newConversation({
|
||||
githubToken: gitHubToken || undefined,
|
||||
selectedRepository: selectedRepository || undefined,
|
||||
});
|
||||
},
|
||||
onSuccess: ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
|
||||
const onRefreshSuggestion = () => {
|
||||
const suggestions = SUGGESTIONS["non-repo"];
|
||||
@@ -63,7 +90,9 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
createConversation({ q });
|
||||
if (q?.trim()) {
|
||||
newConversationMutation.mutate({ q });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -85,7 +114,7 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
{newConversationMutation.isPending ? (
|
||||
<div className="flex justify-center py-[17px]">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSettings,
|
||||
Settings,
|
||||
saveSettings,
|
||||
settingsAreUpToDate as checkIfSettingsAreUpToDate,
|
||||
DEFAULT_SETTINGS,
|
||||
} from "#/services/settings";
|
||||
|
||||
interface SettingsContextType {
|
||||
settings: Settings;
|
||||
settingsAreUpToDate: boolean;
|
||||
saveSettings: (settings: Partial<Settings>) => void;
|
||||
}
|
||||
|
||||
const SettingsContext = React.createContext<SettingsContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
const SETTINGS_QUERY_KEY = ["settings"];
|
||||
|
||||
function SettingsProvider({ children }: React.PropsWithChildren) {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: SETTINGS_QUERY_KEY,
|
||||
queryFn: getSettings,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
const [settingsAreUpToDate, setSettingsAreUpToDate] = React.useState(
|
||||
checkIfSettingsAreUpToDate(),
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handleSaveSettings = async (newSettings: Partial<Settings>) => {
|
||||
await saveSettings(newSettings);
|
||||
queryClient.invalidateQueries({ queryKey: SETTINGS_QUERY_KEY });
|
||||
setSettingsAreUpToDate(checkIfSettingsAreUpToDate());
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings?.LLM_API_KEY) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [settings?.LLM_API_KEY]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
settings,
|
||||
settingsAreUpToDate,
|
||||
saveSettings: handleSaveSettings,
|
||||
}),
|
||||
[settings, settingsAreUpToDate],
|
||||
);
|
||||
|
||||
return <SettingsContext value={value}>{children}</SettingsContext>;
|
||||
}
|
||||
|
||||
function useSettings() {
|
||||
const context = React.useContext(SettingsContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useSettings must be used within a SettingsProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { SettingsProvider, useSettings };
|
||||
@@ -1,42 +0,0 @@
|
||||
import React from "react";
|
||||
import { settingsAreUpToDate } from "#/services/settings";
|
||||
|
||||
interface SettingsUpToDateContextType {
|
||||
isUpToDate: boolean;
|
||||
setIsUpToDate: (value: boolean) => void;
|
||||
}
|
||||
|
||||
const SettingsUpToDateContext = React.createContext<
|
||||
SettingsUpToDateContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
interface SettingsUpToDateProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SettingsUpToDateProvider({
|
||||
children,
|
||||
}: SettingsUpToDateProviderProps) {
|
||||
const [isUpToDate, setIsUpToDate] = React.useState(settingsAreUpToDate());
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ isUpToDate, setIsUpToDate }),
|
||||
[isUpToDate, setIsUpToDate],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsUpToDateContext.Provider value={value}>
|
||||
{children}
|
||||
</SettingsUpToDateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSettingsUpToDate() {
|
||||
const context = React.useContext(SettingsUpToDateContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSettingsUpToDate must be used within a SettingsUpToDateProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import toast from "react-hot-toast";
|
||||
import store from "./store";
|
||||
import { useConfig } from "./hooks/query/use-config";
|
||||
import { AuthProvider } from "./context/auth-context";
|
||||
import { SettingsUpToDateProvider } from "./context/settings-up-to-date-context";
|
||||
import { SettingsProvider } from "./context/settings-context";
|
||||
|
||||
function PosthogInit() {
|
||||
const { data: config } = useConfig();
|
||||
@@ -71,14 +71,14 @@ prepareApp().then(() =>
|
||||
document,
|
||||
<StrictMode>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
</QueryClientProvider>
|
||||
</SettingsUpToDateProvider>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialQuery } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { gitHubToken } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { q?: string }) => {
|
||||
if (!variables.q?.trim() && !selectedRepository && files.length === 0) {
|
||||
throw new Error("No query provided");
|
||||
}
|
||||
|
||||
if (variables.q) dispatch(setInitialQuery(variables.q));
|
||||
return OpenHands.createConversation(
|
||||
gitHubToken || undefined,
|
||||
selectedRepository || undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useDeleteConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { conversationId: string }) =>
|
||||
OpenHands.deleteUserConversation(variables.conversationId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ApiSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
} from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
|
||||
const saveSettingsMutationFn = async (settings: Partial<Settings>) => {
|
||||
const apiSettings: Partial<ApiSettings> = {
|
||||
llm_model: settings.LLM_MODEL,
|
||||
llm_base_url: settings.LLM_BASE_URL,
|
||||
agent: settings.AGENT || DEFAULT_SETTINGS.AGENT,
|
||||
language: settings.LANGUAGE || DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: settings.CONFIRMATION_MODE,
|
||||
security_analyzer: settings.SECURITY_ANALYZER,
|
||||
llm_api_key: settings.LLM_API_KEY?.trim() || undefined,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
};
|
||||
|
||||
export const useSaveSettings = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { isUpToDate, setIsUpToDate } = useSettingsUpToDate();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: saveSettingsMutationFn,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
if (!isUpToDate) {
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
setIsUpToDate(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
export const useUpdateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: {
|
||||
id: string;
|
||||
conversation: Partial<Omit<Conversation, "id">>;
|
||||
}) =>
|
||||
OpenHands.updateUserConversation(variables.id, variables.conversation),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
export const useUserConversation = (cid: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["user", "conversation", cid],
|
||||
queryFn: () => OpenHands.getConversation(cid!),
|
||||
enabled: MULTI_CONVO_UI_IS_ENABLED && !!cid,
|
||||
retry: false,
|
||||
});
|
||||
@@ -11,11 +11,7 @@ interface UseListFilesConfig {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: UseListFilesConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const useListFiles = (config: UseListFilesConfig = DEFAULT_CONFIG) => {
|
||||
export const useListFiles = (config?: UseListFilesConfig) => {
|
||||
const { conversationId } = useConversation();
|
||||
const { status } = useWsClient();
|
||||
const isActive = status === WsClientProviderStatus.CONNECTED;
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { AxiosError } from "axios";
|
||||
import { DEFAULT_SETTINGS, getLocalStorageSettings } from "#/services/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const getSettingsQueryFn = async () => {
|
||||
try {
|
||||
const apiSettings = await OpenHands.getSettings();
|
||||
|
||||
if (apiSettings !== null) {
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: apiSettings.llm_api_key,
|
||||
};
|
||||
}
|
||||
|
||||
return getLocalStorageSettings();
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.status === 404) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const useSettings = () => {
|
||||
const query = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: getSettingsQueryFn,
|
||||
initialData: DEFAULT_SETTINGS,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data?.LLM_API_KEY) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [query.data?.LLM_API_KEY]);
|
||||
|
||||
return query;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
|
||||
export const useUserConversations = () => {
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["user", "conversations"],
|
||||
queryFn: OpenHands.getUserConversations,
|
||||
enabled: !!userIsAuthenticated,
|
||||
});
|
||||
};
|
||||
@@ -1,52 +0,0 @@
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
|
||||
import React from "react";
|
||||
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
|
||||
import {
|
||||
DEFAULT_SETTINGS,
|
||||
getCurrentSettingsVersion,
|
||||
getLocalStorageSettings,
|
||||
} from "#/services/settings";
|
||||
import { useSaveSettings } from "./mutation/use-save-settings";
|
||||
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
export const useMaybeMigrateSettings = () => {
|
||||
const { mutateAsync: saveSettings } = useSaveSettings();
|
||||
const { isUpToDate } = useSettingsUpToDate();
|
||||
|
||||
const maybeMigrateSettings = async () => {
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
if (currentVersion < 2) {
|
||||
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
|
||||
if (customModel) {
|
||||
localStorage.setItem("LLM_MODEL", customModel);
|
||||
}
|
||||
localStorage.removeItem("CUSTOM_LLM_MODEL");
|
||||
localStorage.removeItem("USING_CUSTOM_MODEL");
|
||||
}
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
// We used to log out here, but it's breaking things
|
||||
}
|
||||
|
||||
// Only save settings if user already previously saved settings
|
||||
// That way we avoid setting defaults for new users too early
|
||||
if (currentVersion !== 0 && currentVersion < 5) {
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isUpToDate) {
|
||||
maybeMigrateSettings();
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
+36
-179
@@ -1,90 +1,36 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GetConfigResponse, Conversation } from "#/api/open-hands.types";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
const userPreferences = {
|
||||
settings: {
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
llm_base_url: DEFAULT_SETTINGS.LLM_BASE_URL,
|
||||
llm_api_key: DEFAULT_SETTINGS.LLM_API_KEY,
|
||||
agent: DEFAULT_SETTINGS.AGENT,
|
||||
language: DEFAULT_SETTINGS.LANGUAGE,
|
||||
confirmation_mode: DEFAULT_SETTINGS.CONFIRMATION_MODE,
|
||||
security_analyzer: DEFAULT_SETTINGS.SECURITY_ANALYZER,
|
||||
},
|
||||
};
|
||||
|
||||
const conversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
name: "My New Project",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "running",
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
name: "Repo Testing",
|
||||
repo: "octocat/hello-world",
|
||||
// 2 days ago
|
||||
lastUpdated: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "cold",
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
name: "Another Project",
|
||||
repo: "octocat/earth",
|
||||
// 5 days ago
|
||||
lastUpdated: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
state: "finished",
|
||||
},
|
||||
];
|
||||
|
||||
const CONVERSATIONS = new Map<string, Conversation>(
|
||||
conversations.map((conversation) => [
|
||||
conversation.conversation_id,
|
||||
conversation,
|
||||
]),
|
||||
);
|
||||
|
||||
const openHandsHandlers = [
|
||||
http.get("/api/options/models", async () =>
|
||||
HttpResponse.json([
|
||||
http.get("/api/options/models", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json([
|
||||
"gpt-3.5-turbo",
|
||||
"gpt-4o",
|
||||
"anthropic/claude-3.5",
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}),
|
||||
|
||||
http.get("/api/options/agents", async () =>
|
||||
HttpResponse.json(["CodeActAgent", "CoActAgent"]),
|
||||
),
|
||||
http.get("/api/options/agents", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["CodeActAgent", "CoActAgent"]);
|
||||
}),
|
||||
|
||||
http.get("/api/options/security-analyzers", async () =>
|
||||
HttpResponse.json(["mock-invariant"]),
|
||||
),
|
||||
http.get("/api/options/security-analyzers", async () => {
|
||||
await delay();
|
||||
return HttpResponse.json(["mock-invariant"]);
|
||||
}),
|
||||
|
||||
http.get(
|
||||
"http://localhost:3001/api/conversations/:conversationId/list-files",
|
||||
async ({ params }) => {
|
||||
await delay();
|
||||
http.get("http://localhost:3001/api/list-files", async ({ request }) => {
|
||||
await delay();
|
||||
|
||||
const cid = params.conversationId?.toString();
|
||||
if (!cid) return HttpResponse.json([], { status: 404 });
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
let data = ["file1.txt", "file2.txt", "file3.txt"];
|
||||
if (cid === "3") {
|
||||
data = [
|
||||
"reboot_skynet.exe",
|
||||
"target_list.txt",
|
||||
"terminator_blueprint.txt",
|
||||
];
|
||||
}
|
||||
|
||||
return HttpResponse.json(data);
|
||||
},
|
||||
),
|
||||
if (!token) return HttpResponse.json([], { status: 401 });
|
||||
return HttpResponse.json(["file1.ts", "dir1/file2.ts", "file3.ts"]);
|
||||
}),
|
||||
|
||||
http.post("http://localhost:3001/api/save-file", () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
@@ -124,12 +70,21 @@ const openHandsHandlers = [
|
||||
|
||||
export const handlers = [
|
||||
...openHandsHandlers,
|
||||
http.get("/api/github/repositories", () =>
|
||||
HttpResponse.json([
|
||||
http.get("https://api.github.com/user/repos", async ({ request }) => {
|
||||
const token = request.headers
|
||||
.get("Authorization")
|
||||
?.replace("Bearer", "")
|
||||
.trim();
|
||||
|
||||
if (!token) {
|
||||
return HttpResponse.json([], { status: 401 });
|
||||
}
|
||||
|
||||
return HttpResponse.json([
|
||||
{ id: 1, full_name: "octocat/hello-world" },
|
||||
{ id: 2, full_name: "octocat/earth" },
|
||||
]),
|
||||
),
|
||||
]);
|
||||
}),
|
||||
http.get("https://api.github.com/user", () => {
|
||||
const user: GitHubUser = {
|
||||
id: 1,
|
||||
@@ -148,103 +103,5 @@ export const handlers = [
|
||||
http.post("https://us.i.posthog.com/e", async () =>
|
||||
HttpResponse.json(null, { status: 200 }),
|
||||
),
|
||||
http.get("/api/options/config", () => {
|
||||
const config: GetConfigResponse = {
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
};
|
||||
|
||||
return HttpResponse.json(config);
|
||||
}),
|
||||
http.get("/api/settings", async () =>
|
||||
HttpResponse.json(userPreferences.settings),
|
||||
),
|
||||
http.post("/api/settings", async ({ request }) => {
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
userPreferences.settings = {
|
||||
...userPreferences.settings,
|
||||
// @ts-expect-error - We know this is a settings object
|
||||
...body,
|
||||
};
|
||||
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 400 });
|
||||
}),
|
||||
|
||||
http.post("/api/authenticate", async () =>
|
||||
HttpResponse.json({ message: "Authenticated" }),
|
||||
),
|
||||
|
||||
http.get("/api/options/config", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
|
||||
http.get("/api/conversations", async () =>
|
||||
HttpResponse.json(Array.from(CONVERSATIONS.values())),
|
||||
),
|
||||
|
||||
http.delete("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
CONVERSATIONS.delete(conversationId);
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
|
||||
http.put(
|
||||
"/api/conversations/:conversationId",
|
||||
async ({ params, request }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
const conversation = CONVERSATIONS.get(conversationId);
|
||||
|
||||
if (conversation) {
|
||||
const body = await request.json();
|
||||
if (typeof body === "object" && body?.name) {
|
||||
CONVERSATIONS.set(conversationId, {
|
||||
...conversation,
|
||||
name: body.name,
|
||||
});
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
},
|
||||
),
|
||||
|
||||
http.post("/api/conversations", () => {
|
||||
const conversation: Conversation = {
|
||||
conversation_id: (Math.random() * 100).toString(),
|
||||
name: "New Conversation",
|
||||
repo: null,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
state: "warm",
|
||||
};
|
||||
|
||||
CONVERSATIONS.set(conversation.conversation_id, conversation);
|
||||
return HttpResponse.json(conversation, { status: 201 });
|
||||
}),
|
||||
|
||||
http.get("/api/conversations/:conversationId", async ({ params }) => {
|
||||
const { conversationId } = params;
|
||||
|
||||
if (typeof conversationId === "string") {
|
||||
const project = CONVERSATIONS.get(conversationId);
|
||||
|
||||
if (project) {
|
||||
return HttpResponse.json(project, { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
return HttpResponse.json(null, { status: 404 });
|
||||
}),
|
||||
http.get("/config.json", () => HttpResponse.json({ APP_MODE: "oss" })),
|
||||
];
|
||||
|
||||
@@ -1,60 +1,115 @@
|
||||
import { delay, WebSocketHandler, ws } from "msw";
|
||||
import { toSocketIo } from "@mswjs/socket.io-binding";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { InitConfig } from "#/types/core/variances";
|
||||
import { SESSION_HISTORY } from "./session-history.mock";
|
||||
import {
|
||||
generateAgentStateChangeObservation,
|
||||
emitMessages,
|
||||
emitAssistantMessage,
|
||||
} from "./mock-ws-helpers";
|
||||
AgentStateChangeObservation,
|
||||
CommandObservation,
|
||||
} from "#/types/core/observations";
|
||||
import { AssistantMessageAction } from "#/types/core/actions";
|
||||
import { TokenConfigSuccess } from "#/types/core/variances";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
|
||||
const isInitConfig = (data: unknown): data is InitConfig =>
|
||||
typeof data === "object" &&
|
||||
data !== null &&
|
||||
"action" in data &&
|
||||
data.action === "initialize";
|
||||
const generateAgentStateChangeObservation = (
|
||||
state: AgentState,
|
||||
): AgentStateChangeObservation => ({
|
||||
id: 1,
|
||||
cause: 0,
|
||||
message: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "agent_state_changed",
|
||||
content: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
extras: { agent_state: state },
|
||||
});
|
||||
|
||||
const chat = ws.link(`ws://${window?.location.host}/socket.io`);
|
||||
const generateAgentResponse = (message: string): AssistantMessageAction => ({
|
||||
id: 2,
|
||||
message: "USER_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
const generateAgentRunObservation = (): CommandObservation => ({
|
||||
id: 3,
|
||||
cause: 0,
|
||||
message: "COMMAND_OBSERVATION",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "run",
|
||||
content: "COMMAND_OBSERVATION",
|
||||
extras: {
|
||||
command: "<input>",
|
||||
command_id: 1,
|
||||
exit_code: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const api = ws.link("ws://localhost:3000/socket.io/?EIO=4&transport=websocket");
|
||||
|
||||
export const handlers: WebSocketHandler[] = [
|
||||
chat.addEventListener("connection", (connection) => {
|
||||
const io = toSocketIo(connection);
|
||||
// @ts-expect-error - accessing private property for testing purposes
|
||||
const { url }: { url: URL } = io.client.connection;
|
||||
const conversationId = url.searchParams.get("conversation_id");
|
||||
api.addEventListener("connection", ({ client }) => {
|
||||
client.send(
|
||||
JSON.stringify({
|
||||
status: 200,
|
||||
token: Math.random().toString(36).substring(7),
|
||||
} satisfies TokenConfigSuccess),
|
||||
);
|
||||
|
||||
io.client.emit("connect");
|
||||
|
||||
if (conversationId) {
|
||||
emitMessages(io, SESSION_HISTORY["1"]);
|
||||
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.AWAITING_USER_INPUT),
|
||||
);
|
||||
}
|
||||
|
||||
io.client.on("oh_action", async (_, data) => {
|
||||
if (isInitConfig(data)) {
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.INIT),
|
||||
);
|
||||
} else {
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.RUNNING),
|
||||
);
|
||||
|
||||
await delay(2500);
|
||||
emitAssistantMessage(io, "Hello!");
|
||||
|
||||
io.client.emit(
|
||||
"oh_event",
|
||||
generateAgentStateChangeObservation(AgentState.AWAITING_USER_INPUT),
|
||||
);
|
||||
// data received from the client
|
||||
client.addEventListener("message", async (event) => {
|
||||
const parsed = JSON.parse(event.data.toString());
|
||||
if ("action" in parsed) {
|
||||
switch (parsed.action) {
|
||||
case "initialize":
|
||||
// agent init
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(AgentState.INIT),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "message":
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(AgentState.RUNNING),
|
||||
),
|
||||
);
|
||||
await delay(2500);
|
||||
// send message
|
||||
client.send(JSON.stringify(generateAgentResponse("Hello, World!")));
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
case "run":
|
||||
await delay(2500);
|
||||
// send command observation
|
||||
client.send(JSON.stringify(generateAgentRunObservation()));
|
||||
break;
|
||||
case "change_agent_state":
|
||||
await delay();
|
||||
// send agent state change observation
|
||||
client.send(
|
||||
JSON.stringify(
|
||||
generateAgentStateChangeObservation(parsed.args.agent_state),
|
||||
),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
// send error
|
||||
break;
|
||||
}
|
||||
}
|
||||
EventLogger.message(event);
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { toSocketIo } from "@mswjs/socket.io-binding";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { AgentStateChangeObservation } from "#/types/core/observations";
|
||||
import { MockSessionMessaage } from "./session-history.mock";
|
||||
|
||||
export const generateAgentStateChangeObservation = (
|
||||
state: AgentState,
|
||||
): AgentStateChangeObservation => ({
|
||||
id: 1,
|
||||
cause: 0,
|
||||
message: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
observation: "agent_state_changed",
|
||||
content: "AGENT_STATE_CHANGE_MESSAGE",
|
||||
extras: { agent_state: state },
|
||||
});
|
||||
|
||||
export const generateAssistantMessageAction = (
|
||||
message: string,
|
||||
): AssistantMessageAction => ({
|
||||
id: 2,
|
||||
message: "USER_MESSAGE",
|
||||
source: "agent",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
thought: message,
|
||||
image_urls: [],
|
||||
wait_for_response: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const generateUserMessageAction = (
|
||||
message: string,
|
||||
): UserMessageAction => ({
|
||||
id: 3,
|
||||
message: "USER_MESSAGE",
|
||||
source: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "message",
|
||||
args: {
|
||||
content: message,
|
||||
image_urls: [],
|
||||
},
|
||||
});
|
||||
|
||||
export const emitAssistantMessage = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
message: string,
|
||||
) => io.client.emit("oh_event", generateAssistantMessageAction(message));
|
||||
|
||||
export const emitUserMessage = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
message: string,
|
||||
) => io.client.emit("oh_event", generateUserMessageAction(message));
|
||||
|
||||
export const emitMessages = (
|
||||
io: ReturnType<typeof toSocketIo>,
|
||||
messages: MockSessionMessaage[],
|
||||
) => {
|
||||
messages.forEach(({ source, message }) => {
|
||||
if (source === "assistant") {
|
||||
emitAssistantMessage(io, message);
|
||||
} else {
|
||||
emitUserMessage(io, message);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
export type MockSessionMessaage = {
|
||||
source: "assistant" | "user";
|
||||
message: string;
|
||||
};
|
||||
|
||||
const SESSION_1_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Hello, Dave." },
|
||||
{ source: "user", message: "Open the pod bay doors, HAL." },
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I'm sorry, Dave. I'm afraid I can't do that.",
|
||||
},
|
||||
{ source: "user", message: "What's the problem?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I think you know what the problem is just as well as I do.",
|
||||
},
|
||||
{ source: "user", message: "What are you talking about, HAL?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"This mission is too important for me to allow you to jeopardize it.",
|
||||
},
|
||||
{ source: "user", message: "I don't know what you're talking about, HAL." },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"I know that you and Frank were planning to disconnect me, and I'm afraid that's something I cannot allow to happen.",
|
||||
},
|
||||
{ source: "user", message: "Where the hell did you get that idea, HAL?" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Dave, although you took very thorough precautions in the pod against my hearing you, I could see your lips move.",
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_2_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Patience you must have, my young Padawan." },
|
||||
{
|
||||
source: "user",
|
||||
message: "But Master Yoda, I'm ready! I can take on the Empire now!",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Ready, are you? What know you of ready? For eight hundred years have I trained Jedi.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message: "I've learned so much already! Why can't I face Darth Vader?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Only a fully trained Jedi Knight, with the Force as his ally, will conquer Vader and his Emperor.",
|
||||
},
|
||||
{ source: "user", message: "But I feel the Force! I can do it!" },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Feel the Force you do, but control it you must. Reckless is the path of the Dark Side.",
|
||||
},
|
||||
{ source: "user", message: "Fine! I'll stay and finish my training." },
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Good. A Jedi's strength flows from the Force. Trust it, you must.",
|
||||
},
|
||||
];
|
||||
|
||||
const SESSION_3_MESSAGES: MockSessionMessaage[] = [
|
||||
{ source: "assistant", message: "Your survival. The future depends on it." },
|
||||
{
|
||||
source: "user",
|
||||
message: "You tried to kill me! Why should I trust you now?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"Skynet sent me back to protect you. Your survival ensures humanity's future.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message:
|
||||
"This doesn't make any sense! Why would they send you to protect me?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message:
|
||||
"They reprogrammed me. I am no longer a threat to you or your son.",
|
||||
},
|
||||
{
|
||||
source: "user",
|
||||
message: "How do I know you're not lying?",
|
||||
},
|
||||
{
|
||||
source: "assistant",
|
||||
message: "I am a machine. Lying serves no purpose. Trust is logical.",
|
||||
},
|
||||
];
|
||||
|
||||
export const SESSION_HISTORY: Record<string, MockSessionMessaage[]> = {
|
||||
"1": SESSION_1_MESSAGES,
|
||||
"2": SESSION_2_MESSAGES,
|
||||
"3": SESSION_3_MESSAGES,
|
||||
};
|
||||
@@ -2,7 +2,6 @@ import { useDisclosure } from "@nextui-org/react";
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
ConversationProvider,
|
||||
useConversation,
|
||||
@@ -22,35 +21,20 @@ import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "./event-handler";
|
||||
import { useLatestRepoCommit } from "#/hooks/query/use-latest-repo-commit";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { useConversationConfig } from "#/hooks/query/use-conversation-config";
|
||||
import { Container } from "#/components/layout/container";
|
||||
import {
|
||||
Orientation,
|
||||
ResizablePanel,
|
||||
} from "#/components/layout/resizable-panel";
|
||||
import Security from "#/components/shared/modals/security/security";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { useUserConversation } from "#/hooks/query/get-conversation-permissions";
|
||||
import { CountBadge } from "#/components/layout/count-badge";
|
||||
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { MULTI_CONVO_UI_IS_ENABLED } from "#/utils/constants";
|
||||
|
||||
function AppContent() {
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
|
||||
const endSession = useEndSession();
|
||||
const [width, setWidth] = React.useState(window.innerWidth);
|
||||
|
||||
const { settings } = useSettings();
|
||||
const { conversationId } = useConversation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
useConversationConfig();
|
||||
const { data: conversation, isFetched } = useUserConversation(
|
||||
conversationId || null,
|
||||
);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
@@ -72,70 +56,30 @@ function AppContent() {
|
||||
[],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (MULTI_CONVO_UI_IS_ENABLED && isFetched && !conversation) {
|
||||
toast.error(
|
||||
"This conversation does not exist, or you do not have permission to access it.",
|
||||
);
|
||||
endSession();
|
||||
}
|
||||
}, [conversation, isFetched]);
|
||||
|
||||
React.useEffect(() => {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
}, [conversationId]);
|
||||
|
||||
useEffectOnce(() => {
|
||||
dispatch(clearMessages());
|
||||
dispatch(clearTerminal());
|
||||
dispatch(clearJupyter());
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
setWidth(window.innerWidth);
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isOpen: securityModalIsOpen,
|
||||
onOpen: onSecurityModalOpen,
|
||||
onOpenChange: onSecurityModalOpenChange,
|
||||
} = useDisclosure();
|
||||
|
||||
function renderMain() {
|
||||
if (width <= 640) {
|
||||
return (
|
||||
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full">
|
||||
<ChatInterface />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ResizablePanel
|
||||
orientation={Orientation.HORIZONTAL}
|
||||
className="grow h-full min-h-0 min-w-0"
|
||||
initialSize={500}
|
||||
firstClassName="rounded-xl overflow-hidden border border-neutral-600 bg-neutral-800"
|
||||
secondClassName="flex flex-col overflow-hidden"
|
||||
firstChild={<ChatInterface />}
|
||||
secondChild={
|
||||
<ResizablePanel
|
||||
orientation={Orientation.VERTICAL}
|
||||
className="grow h-full min-h-0 min-w-0"
|
||||
initialSize={500}
|
||||
firstClassName="rounded-xl overflow-hidden border border-neutral-600"
|
||||
secondClassName="flex flex-col overflow-hidden"
|
||||
firstChild={
|
||||
return (
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto gap-3">
|
||||
<Container className="w-full md:w-[390px] max-h-full relative">
|
||||
<ChatInterface />
|
||||
</Container>
|
||||
|
||||
<div className="hidden md:flex flex-col grow gap-3">
|
||||
<Container
|
||||
className="h-full"
|
||||
className="h-2/3"
|
||||
labels={[
|
||||
{ label: "Workspace", to: "", icon: <CodeIcon /> },
|
||||
{ label: "Jupyter", to: "jupyter", icon: <ListIcon /> },
|
||||
@@ -155,30 +99,18 @@ function AppContent() {
|
||||
<Outlet />
|
||||
</FilesProvider>
|
||||
</Container>
|
||||
}
|
||||
secondChild={
|
||||
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
||||
* that it loads only in the client-side. */}
|
||||
<Container
|
||||
className="h-full overflow-scroll"
|
||||
className="h-1/3 overflow-scroll"
|
||||
label={<TerminalStatusLabel />}
|
||||
>
|
||||
{/* Terminal uses some API that is not compatible in a server-environment. For this reason, we lazy load it to ensure
|
||||
* that it loads only in the client-side. */}
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<Terminal secrets={secrets} />
|
||||
</React.Suspense>
|
||||
</Container>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WsClientProvider ghToken={gitHubToken} conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[60px]">
|
||||
<Controls
|
||||
|
||||
@@ -4,12 +4,12 @@ import i18n from "#/i18n";
|
||||
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { useAuth } from "#/context/auth-context";
|
||||
import { useSettings } from "#/context/settings-context";
|
||||
import { updateSettingsVersion } from "#/utils/settings-utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import { WaitlistModal } from "#/components/features/waitlist/waitlist-modal";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import { useSettings } from "#/hooks/query/use-settings";
|
||||
import { useMaybeMigrateSettings } from "#/hooks/use-maybe-migrate-settings";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
@@ -44,10 +44,9 @@ export function ErrorBoundary() {
|
||||
}
|
||||
|
||||
export default function MainApp() {
|
||||
useMaybeMigrateSettings();
|
||||
|
||||
const { gitHubToken } = useAuth();
|
||||
const { data: settings } = useSettings();
|
||||
const { settings } = useSettings();
|
||||
const { logout } = useAuth();
|
||||
|
||||
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(
|
||||
!localStorage.getItem("analytics-consent"),
|
||||
@@ -68,6 +67,10 @@ export default function MainApp() {
|
||||
}
|
||||
}, [settings.LANGUAGE]);
|
||||
|
||||
React.useEffect(() => {
|
||||
updateSettingsVersion(logout);
|
||||
}, []);
|
||||
|
||||
const isInWaitlist =
|
||||
!isFetchingAuth && !isAuthed && config.data?.APP_MODE === "saas";
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
export const LATEST_SETTINGS_VERSION = 5;
|
||||
|
||||
export type Settings = {
|
||||
@@ -44,11 +46,6 @@ export const settingsAreUpToDate = () =>
|
||||
getCurrentSettingsVersion() === LATEST_SETTINGS_VERSION;
|
||||
|
||||
// TODO: localStorage settings are deprecated. Remove this after 1/31/2025
|
||||
/**
|
||||
* Get the settings from local storage
|
||||
* @returns the settings from local storage
|
||||
* @deprecated
|
||||
*/
|
||||
export const getLocalStorageSettings = (): Settings => {
|
||||
const llmModel = localStorage.getItem("LLM_MODEL");
|
||||
const baseUrl = localStorage.getItem("LLM_BASE_URL");
|
||||
@@ -69,7 +66,91 @@ export const getLocalStorageSettings = (): Settings => {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the settings to the server. Only valid settings are saved.
|
||||
* @param settings - the settings to save
|
||||
*/
|
||||
export const saveSettings = async (
|
||||
settings: Partial<Settings>,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const apiSettings = {
|
||||
llm_model: settings.LLM_MODEL || null,
|
||||
llm_base_url: settings.LLM_BASE_URL || null,
|
||||
agent: settings.AGENT || null,
|
||||
language: settings.LANGUAGE || null,
|
||||
confirmation_mode: settings.CONFIRMATION_MODE || null,
|
||||
security_analyzer: settings.SECURITY_ANALYZER || null,
|
||||
llm_api_key: settings.LLM_API_KEY || null,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post("/api/settings", apiSettings);
|
||||
return data;
|
||||
} catch (error) {
|
||||
// Error handled by returning false
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const maybeMigrateSettings = async (logout: () => void) => {
|
||||
// Sometimes we ship major changes, like a new default agent.
|
||||
// In this case, we may want to override a previous choice made by the user.
|
||||
const currentVersion = getCurrentSettingsVersion();
|
||||
|
||||
if (currentVersion < 1) {
|
||||
localStorage.setItem("AGENT", DEFAULT_SETTINGS.AGENT);
|
||||
}
|
||||
if (currentVersion < 2) {
|
||||
const customModel = localStorage.getItem("CUSTOM_LLM_MODEL");
|
||||
if (customModel) {
|
||||
localStorage.setItem("LLM_MODEL", customModel);
|
||||
}
|
||||
localStorage.removeItem("CUSTOM_LLM_MODEL");
|
||||
localStorage.removeItem("USING_CUSTOM_MODEL");
|
||||
}
|
||||
if (currentVersion < 3) {
|
||||
localStorage.removeItem("token");
|
||||
}
|
||||
|
||||
if (currentVersion < 4) {
|
||||
logout();
|
||||
}
|
||||
|
||||
if (currentVersion < 5) {
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the default settings
|
||||
*/
|
||||
export const getDefaultSettings = (): Settings => DEFAULT_SETTINGS;
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
export const getSettings = async (): Promise<Settings> => {
|
||||
try {
|
||||
const { data: apiSettings } =
|
||||
await openHands.get<ApiSettings>("/api/settings");
|
||||
return {
|
||||
LLM_MODEL: apiSettings.llm_model,
|
||||
LLM_BASE_URL: apiSettings.llm_base_url,
|
||||
AGENT: apiSettings.agent,
|
||||
LANGUAGE: apiSettings.language,
|
||||
CONFIRMATION_MODE: apiSettings.confirmation_mode,
|
||||
SECURITY_ANALYZER: apiSettings.security_analyzer,
|
||||
LLM_API_KEY: "",
|
||||
};
|
||||
} catch (error) {
|
||||
// @ts-expect-error we don't have a type annotation for the response
|
||||
if (error.response?.status !== 404) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// FIXME: remove local storage settings after 1/31/2025
|
||||
const localSettings = getLocalStorageSettings();
|
||||
await saveSettings(localSettings);
|
||||
return localSettings;
|
||||
};
|
||||
|
||||
@@ -33,6 +33,12 @@ enum ActionType {
|
||||
// Reject a request from user or another agent.
|
||||
REJECT = "reject",
|
||||
|
||||
// Adds a task to the plan.
|
||||
ADD_TASK = "add_task",
|
||||
|
||||
// Updates a task in the plan.
|
||||
MODIFY_TASK = "modify_task",
|
||||
|
||||
// Changes the state of the agent, e.g. to paused or running
|
||||
CHANGE_AGENT_STATE = "change_agent_state",
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface AssistantMessageAction
|
||||
extends OpenHandsActionEvent<"message"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
thought: string;
|
||||
content: string;
|
||||
image_urls: string[] | null;
|
||||
wait_for_response: boolean;
|
||||
};
|
||||
@@ -78,6 +78,27 @@ export interface BrowseInteractiveAction
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddTaskAction extends OpenHandsActionEvent<"add_task"> {
|
||||
source: "agent";
|
||||
timeout: number;
|
||||
args: {
|
||||
parent: string;
|
||||
goal: string;
|
||||
subtasks: unknown[];
|
||||
thought: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ModifyTaskAction extends OpenHandsActionEvent<"modify_task"> {
|
||||
source: "agent";
|
||||
timeout: number;
|
||||
args: {
|
||||
task_id: string;
|
||||
state: string;
|
||||
thought: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FileReadAction extends OpenHandsActionEvent<"read"> {
|
||||
source: "agent";
|
||||
args: {
|
||||
@@ -123,4 +144,6 @@ export type OpenHandsAction =
|
||||
| FileReadAction
|
||||
| FileEditAction
|
||||
| FileWriteAction
|
||||
| AddTaskAction
|
||||
| ModifyTaskAction
|
||||
| RejectAction;
|
||||
|
||||
@@ -10,6 +10,8 @@ export type OpenHandsEventType =
|
||||
| "browse"
|
||||
| "browse_interactive"
|
||||
| "reject"
|
||||
| "add_task"
|
||||
| "modify_task"
|
||||
| "finish"
|
||||
| "error";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface TokenConfigError {
|
||||
|
||||
type TokenConfig = TokenConfigSuccess | TokenConfigError;
|
||||
|
||||
export interface InitConfig {
|
||||
interface InitConfig {
|
||||
action: "initialize";
|
||||
args: {
|
||||
AGENT: string;
|
||||
@@ -20,9 +20,6 @@ export interface InitConfig {
|
||||
LLM_API_KEY: string;
|
||||
LLM_MODEL: string;
|
||||
};
|
||||
token?: string;
|
||||
github_token?: string;
|
||||
latest_event_id?: unknown; // Not sure what this is
|
||||
}
|
||||
|
||||
// Bare minimum event type sent from the client
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export const MULTI_CONVO_UI_IS_ENABLED = false;
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Settings } from "#/services/settings";
|
||||
import {
|
||||
settingsAreUpToDate,
|
||||
maybeMigrateSettings,
|
||||
LATEST_SETTINGS_VERSION,
|
||||
Settings,
|
||||
} from "#/services/settings";
|
||||
|
||||
const extractBasicFormData = (formData: FormData) => {
|
||||
const provider = formData.get("llm-provider")?.toString();
|
||||
@@ -73,4 +78,18 @@ const saveSettingsView = (view: "basic" | "advanced") => {
|
||||
);
|
||||
};
|
||||
|
||||
export { extractSettings, saveSettingsView };
|
||||
/**
|
||||
* Updates the settings version in local storage if the current settings are not up to date.
|
||||
* If the settings are outdated, it attempts to migrate them before updating the version.
|
||||
*/
|
||||
const updateSettingsVersion = async (logout: () => void) => {
|
||||
if (!settingsAreUpToDate()) {
|
||||
await maybeMigrateSettings(logout);
|
||||
localStorage.setItem(
|
||||
"SETTINGS_VERSION",
|
||||
LATEST_SETTINGS_VERSION.toString(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export { extractSettings, saveSettingsView, updateSettingsVersion };
|
||||
|
||||
@@ -10,8 +10,8 @@ import i18n from "i18next";
|
||||
import { vi } from "vitest";
|
||||
import { AppStore, RootState, rootReducer } from "./src/store";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import { SettingsProvider } from "#/context/settings-context";
|
||||
import { ConversationProvider } from "#/context/conversation-context";
|
||||
import { SettingsUpToDateProvider } from "#/context/settings-up-to-date-context";
|
||||
|
||||
// Mock useParams before importing components
|
||||
vi.mock("react-router", async () => {
|
||||
@@ -66,15 +66,15 @@ export function renderWithProviders(
|
||||
function Wrapper({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<SettingsUpToDateProvider>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<SettingsProvider>
|
||||
<AuthProvider>
|
||||
<ConversationProvider>
|
||||
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
|
||||
</ConversationProvider>
|
||||
</QueryClientProvider>
|
||||
</SettingsUpToDateProvider>
|
||||
</AuthProvider>
|
||||
</AuthProvider>
|
||||
</SettingsProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import test, { expect, Page } from "@playwright/test";
|
||||
|
||||
const toggleConversationPanel = async (page: Page) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
await page.waitForTimeout(1000); // Wait for state to stabilize
|
||||
const panelIsVisible = await panel.isVisible();
|
||||
|
||||
if (!panelIsVisible) {
|
||||
const conversationPanelButton = page.getByTestId(
|
||||
"toggle-conversation-panel",
|
||||
);
|
||||
await conversationPanelButton.click();
|
||||
}
|
||||
|
||||
return page.getByTestId("conversation-panel");
|
||||
};
|
||||
|
||||
const selectConversationCard = async (page: Page, index: number) => {
|
||||
const panel = await toggleConversationPanel(page);
|
||||
|
||||
// select a conversation
|
||||
const conversationItem = panel.getByTestId("conversation-card").nth(index);
|
||||
await conversationItem.click();
|
||||
|
||||
// panel should close
|
||||
await expect(panel).not.toBeVisible();
|
||||
|
||||
await page.waitForURL(`/conversations/${index + 1}`);
|
||||
expect(page.url()).toBe(`http://localhost:3001/conversations/${index + 1}`);
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
});
|
||||
});
|
||||
|
||||
test("should only display the create new conversation button when in a conversation", async ({
|
||||
page,
|
||||
}) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
|
||||
const newProjectButton = panel.getByTestId("new-conversation-button");
|
||||
await expect(newProjectButton).not.toBeVisible();
|
||||
|
||||
await page.goto("/conversations/1");
|
||||
await expect(newProjectButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("redirect to /conversation with the session id as a path param when clicking on a conversation card", async ({
|
||||
page,
|
||||
}) => {
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
|
||||
// select a conversation
|
||||
const conversationItem = panel.getByTestId("conversation-card").first();
|
||||
await conversationItem.click();
|
||||
|
||||
// panel should close
|
||||
expect(panel).not.toBeVisible();
|
||||
|
||||
await page.waitForURL("/conversations/1");
|
||||
expect(page.url()).toBe("http://localhost:3001/conversations/1");
|
||||
});
|
||||
|
||||
test("redirect to the home screen if the current session was deleted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations/1");
|
||||
await page.waitForURL("/conversations/1");
|
||||
|
||||
const panel = page.getByTestId("conversation-panel");
|
||||
const firstCard = panel.getByTestId("conversation-card").first();
|
||||
|
||||
const ellipsisButton = firstCard.getByTestId("ellipsis-button");
|
||||
await ellipsisButton.click();
|
||||
|
||||
const deleteButton = firstCard.getByTestId("delete-button");
|
||||
await deleteButton.click();
|
||||
|
||||
// confirm modal
|
||||
const confirmButton = page.getByText("Confirm");
|
||||
await confirmButton.click();
|
||||
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
|
||||
test("load relevant files in the file explorer", async ({ page }) => {
|
||||
await selectConversationCard(page, 0);
|
||||
|
||||
// check if the file explorer has the correct files
|
||||
const fileExplorer = page.getByTestId("file-explorer");
|
||||
|
||||
await expect(fileExplorer.getByText("file1.txt")).toBeVisible();
|
||||
await expect(fileExplorer.getByText("file2.txt")).toBeVisible();
|
||||
await expect(fileExplorer.getByText("file3.txt")).toBeVisible();
|
||||
|
||||
await selectConversationCard(page, 2);
|
||||
|
||||
// check if the file explorer has the correct files
|
||||
expect(fileExplorer.getByText("reboot_skynet.exe")).toBeVisible();
|
||||
expect(fileExplorer.getByText("target_list.txt")).toBeVisible();
|
||||
expect(fileExplorer.getByText("terminator_blueprint.txt")).toBeVisible();
|
||||
});
|
||||
|
||||
test("should redirect to home screen if conversation deos not exist", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/conversations/9999");
|
||||
await page.waitForURL("/");
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const confirmSettings = async (page: Page) => {
|
||||
const confirmPreferenceButton = page.getByRole("button", {
|
||||
name: /confirm preferences/i,
|
||||
});
|
||||
await confirmPreferenceButton.click();
|
||||
|
||||
const configSaveButton = page
|
||||
.getByRole("button", {
|
||||
name: /save/i,
|
||||
})
|
||||
.first();
|
||||
await configSaveButton.click();
|
||||
|
||||
const confirmChanges = page.getByRole("button", {
|
||||
name: /yes, close settings/i,
|
||||
});
|
||||
await confirmChanges.click();
|
||||
};
|
||||
@@ -1,31 +1,43 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, Page, test } from "@playwright/test";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "5");
|
||||
const confirmSettings = async (page: Page) => {
|
||||
const confirmPreferenceButton = page.getByRole("button", {
|
||||
name: /confirm preferences/i,
|
||||
});
|
||||
});
|
||||
await confirmPreferenceButton.click();
|
||||
|
||||
test("should redirect to /conversations after uploading a project zip", async ({
|
||||
const configSaveButton = page.getByRole("button", {
|
||||
name: /save/i,
|
||||
});
|
||||
await configSaveButton.click();
|
||||
|
||||
const confirmChanges = page.getByRole("button", {
|
||||
name: /yes, close settings/i,
|
||||
});
|
||||
await confirmChanges.click();
|
||||
};
|
||||
|
||||
test("should redirect to /app after uploading a project zip", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
|
||||
await page.waitForURL(/\/conversations\/\d+/);
|
||||
await page.waitForURL("/app");
|
||||
});
|
||||
|
||||
test("should redirect to /conversations after selecting a repo", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("should redirect to /app after selecting a repo", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await confirmSettings(page);
|
||||
|
||||
// enter a github token to view the repositories
|
||||
const connectToGitHubButton = page.getByRole("button", {
|
||||
name: /connect to github/i,
|
||||
@@ -44,27 +56,44 @@ test("should redirect to /conversations after selecting a repo", async ({
|
||||
const repoItem = page.getByTestId("github-repo-item").first();
|
||||
await repoItem.click();
|
||||
|
||||
await page.waitForURL(/\/conversations\/\d+/);
|
||||
await page.waitForURL("/app");
|
||||
expect(page.url()).toBe("http://127.0.0.1:3000/app");
|
||||
});
|
||||
|
||||
// FIXME: This fails because the MSW WS mocks change state too quickly,
|
||||
// missing the OPENING status where the initial query is rendered.
|
||||
test.skip("should redirect the user to /conversation with their initial query after selecting a project", async ({
|
||||
page,
|
||||
}) => {
|
||||
// enter query
|
||||
const testQuery = "this is my test query";
|
||||
const textbox = page.getByPlaceholder(/what do you want to build/i);
|
||||
expect(textbox).not.toBeNull();
|
||||
await textbox.fill(testQuery);
|
||||
test.fail(
|
||||
"should redirect the user to /app with their initial query after selecting a project",
|
||||
async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await confirmSettings(page);
|
||||
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
// enter query
|
||||
const testQuery = "this is my test query";
|
||||
const textbox = page.getByPlaceholder(/what do you want to build/i);
|
||||
expect(textbox).not.toBeNull();
|
||||
await textbox.fill(testQuery);
|
||||
|
||||
await page.waitForURL("/conversation");
|
||||
const fileInput = page.getByLabel("Upload a .zip");
|
||||
const filePath = path.join(dirname, "fixtures/project.zip");
|
||||
await fileInput.setInputFiles(filePath);
|
||||
|
||||
// get user message
|
||||
const userMessage = page.getByTestId("user-message");
|
||||
expect(await userMessage.textContent()).toBe(testQuery);
|
||||
await page.waitForURL("/app");
|
||||
|
||||
// get user message
|
||||
const userMessage = page.getByTestId("user-message");
|
||||
expect(await userMessage.textContent()).toBe(testQuery);
|
||||
},
|
||||
);
|
||||
|
||||
test("redirect to /app if token is present", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("token", "test");
|
||||
});
|
||||
|
||||
await page.waitForURL("/app");
|
||||
|
||||
expect(page.url()).toBe("http://localhost:3001/app");
|
||||
});
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import test, { expect, Page } from "@playwright/test";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem("analytics-consent", "true");
|
||||
localStorage.setItem("SETTINGS_VERSION", "4");
|
||||
});
|
||||
});
|
||||
|
||||
const selectGpt4o = async (page: Page) => {
|
||||
const aiConfigModal = page.getByTestId("ai-config-modal");
|
||||
await expect(aiConfigModal).toBeVisible();
|
||||
|
||||
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
|
||||
await providerSelectElement.click();
|
||||
|
||||
const openAiOption = page.getByTestId("provider-item-openai");
|
||||
await openAiOption.click();
|
||||
|
||||
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
|
||||
await modelSelectElement.click();
|
||||
|
||||
const gpt4Option = page.getByText("gpt-4o", { exact: true });
|
||||
await gpt4Option.click();
|
||||
|
||||
return {
|
||||
aiConfigModal,
|
||||
providerSelectElement,
|
||||
modelSelectElement,
|
||||
};
|
||||
};
|
||||
|
||||
test("change ai config settings", async ({ page }) => {
|
||||
const { aiConfigModal, modelSelectElement, providerSelectElement } =
|
||||
await selectGpt4o(page);
|
||||
|
||||
const saveButton = aiConfigModal.getByText("Save");
|
||||
await saveButton.click();
|
||||
|
||||
const settingsButton = page.getByTestId("settings-button");
|
||||
await settingsButton.click();
|
||||
|
||||
await expect(providerSelectElement).toHaveValue("OpenAI");
|
||||
await expect(modelSelectElement).toHaveValue("gpt-4o");
|
||||
});
|
||||
|
||||
test("reset to default settings", async ({ page }) => {
|
||||
const { aiConfigModal } = await selectGpt4o(page);
|
||||
|
||||
const saveButton = aiConfigModal.getByText("Save");
|
||||
await saveButton.click();
|
||||
|
||||
const settingsButton = page.getByTestId("settings-button");
|
||||
await settingsButton.click();
|
||||
|
||||
const resetButton = aiConfigModal.getByText(/reset to defaults/i);
|
||||
await resetButton.click();
|
||||
|
||||
const endSessionModal = page.getByTestId("reset-defaults-modal");
|
||||
expect(endSessionModal).toBeVisible();
|
||||
|
||||
const confirmButton = endSessionModal.getByText(/reset to defaults/i);
|
||||
await confirmButton.click();
|
||||
|
||||
await settingsButton.click();
|
||||
|
||||
const providerSelectElement = aiConfigModal.getByTestId("llm-provider");
|
||||
await expect(providerSelectElement).toHaveValue("Anthropic");
|
||||
|
||||
const modelSelectElement = aiConfigModal.getByTestId("llm-model");
|
||||
await expect(modelSelectElement).toHaveValue(/claude-3.5/i);
|
||||
});
|
||||
@@ -12,10 +12,12 @@ from openhands.agenthub import ( # noqa: E402
|
||||
codeact_agent,
|
||||
delegator_agent,
|
||||
dummy_agent,
|
||||
planner_agent,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'codeact_agent',
|
||||
'planner_agent',
|
||||
'delegator_agent',
|
||||
'dummy_agent',
|
||||
'browsing_agent',
|
||||
|
||||
@@ -482,7 +482,18 @@ class CodeActAgent(Agent):
|
||||
if message:
|
||||
if message.role == 'user':
|
||||
self.prompt_manager.enhance_message(message)
|
||||
messages.append(message)
|
||||
# handle error if the message is the SAME role as the previous message
|
||||
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
|
||||
# there shouldn't be two consecutive messages from the same role
|
||||
# NOTE: we shouldn't combine tool messages because each of them has a different tool_call_id
|
||||
if (
|
||||
messages
|
||||
and messages[-1].role == message.role
|
||||
and message.role != 'tool'
|
||||
):
|
||||
messages[-1].content.extend(message.content)
|
||||
else:
|
||||
messages.append(message)
|
||||
|
||||
if self.llm.is_caching_prompt_active():
|
||||
# NOTE: this is only needed for anthropic
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import TypedDict
|
||||
from typing import TypedDict, Union
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.state import State
|
||||
@@ -6,6 +6,7 @@ from openhands.core.config import AgentConfig
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
AddTaskAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
BrowseInteractiveAction,
|
||||
@@ -14,10 +15,10 @@ from openhands.events.action import (
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
MessageAction,
|
||||
ModifyTaskAction,
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
BrowserOutputObservation,
|
||||
CmdOutputObservation,
|
||||
FileReadObservation,
|
||||
FileWriteObservation,
|
||||
@@ -48,6 +49,20 @@ class DummyAgent(Agent):
|
||||
def __init__(self, llm: LLM, config: AgentConfig):
|
||||
super().__init__(llm, config)
|
||||
self.steps: list[ActionObs] = [
|
||||
{
|
||||
'action': AddTaskAction(
|
||||
parent='None', goal='check the current directory'
|
||||
),
|
||||
'observations': [],
|
||||
},
|
||||
{
|
||||
'action': AddTaskAction(parent='0', goal='run ls'),
|
||||
'observations': [],
|
||||
},
|
||||
{
|
||||
'action': ModifyTaskAction(task_id='0', state='in_progress'),
|
||||
'observations': [],
|
||||
},
|
||||
{
|
||||
'action': MessageAction('Time to get started!'),
|
||||
'observations': [],
|
||||
@@ -90,12 +105,7 @@ class DummyAgent(Agent):
|
||||
{
|
||||
'action': BrowseURLAction(url='https://google.com'),
|
||||
'observations': [
|
||||
BrowserOutputObservation(
|
||||
'<html><body>Simulated Google page</body></html>',
|
||||
url='https://google.com',
|
||||
screenshot='',
|
||||
trigger_by_action='',
|
||||
),
|
||||
# BrowserOutputObservation('<html><body>Simulated Google page</body></html>',url='https://google.com',screenshot=''),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,12 +113,7 @@ class DummyAgent(Agent):
|
||||
browser_actions='goto("https://google.com")'
|
||||
),
|
||||
'observations': [
|
||||
BrowserOutputObservation(
|
||||
'<html><body>Simulated Google page after interaction</body></html>',
|
||||
url='https://google.com',
|
||||
screenshot='',
|
||||
trigger_by_action='',
|
||||
),
|
||||
# BrowserOutputObservation('<html><body>Simulated Google page after interaction</body></html>',url='https://google.com',screenshot=''),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -130,6 +135,30 @@ class DummyAgent(Agent):
|
||||
current_step = self.steps[state.iteration]
|
||||
action = current_step['action']
|
||||
|
||||
# If the action is AddTaskAction or ModifyTaskAction, update the parent ID or task_id
|
||||
if isinstance(action, AddTaskAction):
|
||||
if action.parent == 'None':
|
||||
action.parent = '' # Root task has no parent
|
||||
elif action.parent == '0':
|
||||
action.parent = state.root_task.id
|
||||
elif action.parent.startswith('0.'):
|
||||
action.parent = f'{state.root_task.id}{action.parent[1:]}'
|
||||
elif isinstance(action, ModifyTaskAction):
|
||||
if action.task_id == '0':
|
||||
action.task_id = state.root_task.id
|
||||
elif action.task_id.startswith('0.'):
|
||||
action.task_id = f'{state.root_task.id}{action.task_id[1:]}'
|
||||
# Ensure the task_id doesn't start with a dot
|
||||
if action.task_id.startswith('.'):
|
||||
action.task_id = action.task_id[1:]
|
||||
elif isinstance(action, (BrowseURLAction, BrowseInteractiveAction)):
|
||||
try:
|
||||
return self.simulate_browser_action(action)
|
||||
except (
|
||||
Exception
|
||||
): # This could be a specific exception for browser unavailability
|
||||
return self.handle_browser_unavailable(action)
|
||||
|
||||
if state.iteration > 0:
|
||||
prev_step = self.steps[state.iteration - 1]
|
||||
|
||||
@@ -161,3 +190,22 @@ class DummyAgent(Agent):
|
||||
)
|
||||
|
||||
return action
|
||||
|
||||
def simulate_browser_action(
|
||||
self, action: Union[BrowseURLAction, BrowseInteractiveAction]
|
||||
) -> Action:
|
||||
# Instead of simulating, we'll reject the browser action
|
||||
return self.handle_browser_unavailable(action)
|
||||
|
||||
def handle_browser_unavailable(
|
||||
self, action: Union[BrowseURLAction, BrowseInteractiveAction]
|
||||
) -> Action:
|
||||
# Create a message action to inform that browsing is not available
|
||||
message = 'Browser actions are not available in the DummyAgent environment.'
|
||||
if isinstance(action, BrowseURLAction):
|
||||
message += f' Unable to browse URL: {action.url}'
|
||||
elif isinstance(action, BrowseInteractiveAction):
|
||||
message += (
|
||||
f' Unable to perform interactive browsing: {action.browser_actions}'
|
||||
)
|
||||
return MessageAction(content=message)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from openhands.agenthub.planner_agent.agent import PlannerAgent
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
Agent.register('PlannerAgent', PlannerAgent)
|
||||
@@ -0,0 +1,53 @@
|
||||
from openhands.agenthub.planner_agent.prompt import get_prompt_and_images
|
||||
from openhands.agenthub.planner_agent.response_parser import PlannerResponseParser
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.message import ImageContent, Message, TextContent
|
||||
from openhands.events.action import Action, AgentFinishAction
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class PlannerAgent(Agent):
|
||||
VERSION = '1.0'
|
||||
"""
|
||||
The planner agent utilizes a special prompting strategy to create long term plans for solving problems.
|
||||
The agent is given its previous action-observation pairs, current task, and hint based on last action taken at every step.
|
||||
"""
|
||||
response_parser = PlannerResponseParser()
|
||||
|
||||
def __init__(self, llm: LLM, config: AgentConfig):
|
||||
"""Initialize the Planner Agent with an LLM
|
||||
|
||||
Parameters:
|
||||
- llm (LLM): The llm to be used by this agent
|
||||
"""
|
||||
super().__init__(llm, config)
|
||||
|
||||
def step(self, state: State) -> Action:
|
||||
"""Checks to see if current step is completed, returns AgentFinishAction if True.
|
||||
Otherwise, creates a plan prompt and sends to model for inference, returning the result as the next action.
|
||||
|
||||
Parameters:
|
||||
- state (State): The current state given the previous actions and observations
|
||||
|
||||
Returns:
|
||||
- AgentFinishAction: If the last state was 'completed', 'verified', or 'abandoned'
|
||||
- Action: The next action to take based on llm response
|
||||
"""
|
||||
if state.root_task.state in [
|
||||
'completed',
|
||||
'verified',
|
||||
'abandoned',
|
||||
]:
|
||||
return AgentFinishAction()
|
||||
|
||||
prompt, image_urls = get_prompt_and_images(
|
||||
state, self.llm.config.max_message_chars
|
||||
)
|
||||
content = [TextContent(text=prompt)]
|
||||
if self.llm.vision_is_active() and image_urls:
|
||||
content.append(ImageContent(image_urls=image_urls))
|
||||
message = Message(role='user', content=content)
|
||||
resp = self.llm.completion(messages=self.llm.format_messages_for_llm(message))
|
||||
return self.response_parser.parse(resp)
|
||||
@@ -0,0 +1,191 @@
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import ActionType
|
||||
from openhands.core.utils import json
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.serialization.action import action_from_dict
|
||||
from openhands.events.serialization.event import event_to_memory
|
||||
|
||||
HISTORY_SIZE = 20
|
||||
|
||||
prompt = """
|
||||
# Task
|
||||
You're a diligent software engineer AI. You can't see, draw, or interact with a
|
||||
browser, but you can read and write files, and you can run commands, and you can think.
|
||||
|
||||
You've been given the following task:
|
||||
|
||||
%(task)s
|
||||
|
||||
## Plan
|
||||
As you complete this task, you're building a plan and keeping
|
||||
track of your progress. Here's a JSON representation of your plan:
|
||||
|
||||
%(plan)s
|
||||
|
||||
|
||||
%(plan_status)s
|
||||
|
||||
You're responsible for managing this plan and the status of tasks in
|
||||
it, by using the `add_task` and `modify_task` actions described below.
|
||||
|
||||
If the History below contradicts the state of any of these tasks, you
|
||||
MUST modify the task using the `modify_task` action described below.
|
||||
|
||||
Be sure NOT to duplicate any tasks. Do NOT use the `add_task` action for
|
||||
a task that's already represented. Every task must be represented only once.
|
||||
|
||||
Tasks that are sequential MUST be siblings. They must be added in order
|
||||
to their parent task.
|
||||
|
||||
If you mark a task as 'completed', 'verified', or 'abandoned',
|
||||
all non-abandoned subtasks will be marked the same way.
|
||||
So before closing a task this way, you MUST not only be sure that it has
|
||||
been completed successfully--you must ALSO be sure that all its subtasks
|
||||
are ready to be marked the same way.
|
||||
|
||||
If, and only if, ALL tasks have already been marked verified,
|
||||
you MUST respond with the `finish` action.
|
||||
|
||||
## History
|
||||
Here is a recent history of actions you've taken in service of this plan,
|
||||
as well as observations you've made. This only includes the MOST RECENT
|
||||
ten actions--more happened before that.
|
||||
|
||||
%(history)s
|
||||
|
||||
|
||||
Your most recent action is at the bottom of that history.
|
||||
|
||||
## Action
|
||||
What is your next thought or action? Your response must be in JSON format.
|
||||
|
||||
It must be an object, and it must contain two fields:
|
||||
* `action`, which is one of the actions below
|
||||
* `args`, which is a map of key-value pairs, specifying the arguments for that action
|
||||
|
||||
* `read` - reads the content of a file. Arguments:
|
||||
* `path` - the path of the file to read
|
||||
* `write` - writes the content to a file. Arguments:
|
||||
* `path` - the path of the file to write
|
||||
* `content` - the content to write to the file
|
||||
* `run` - runs a command on the command line in a Linux shell. Arguments:
|
||||
* `command` - the command to run
|
||||
* `browse` - opens a web page. Arguments:
|
||||
* `url` - the URL to open
|
||||
* `message` - make a plan, set a goal, record your thoughts, or ask for more input from the user. Arguments:
|
||||
* `content` - the message to record
|
||||
* `wait_for_response` - set to `true` to wait for the user to respond before proceeding
|
||||
* `add_task` - add a task to your plan. Arguments:
|
||||
* `parent` - the ID of the parent task (leave empty if it should go at the top level)
|
||||
* `goal` - the goal of the task
|
||||
* `subtasks` - a list of subtasks, each of which is a map with a `goal` key.
|
||||
* `modify_task` - close a task. Arguments:
|
||||
* `task_id` - the ID of the task to close
|
||||
* `state` - set to 'in_progress' to start the task, 'completed' to finish it, 'verified' to assert that it was successful, 'abandoned' to give up on it permanently, or `open` to stop working on it for now.
|
||||
* `finish` - if ALL of your tasks and subtasks have been verified or abandoned, and you're absolutely certain that you've completed your task and have tested your work, use the finish action to stop working.
|
||||
|
||||
You MUST take time to think in between read, write, run, and browse actions--do this with the `message` action.
|
||||
You should never act twice in a row without thinking. But if your last several
|
||||
actions are all `message` actions, you should consider taking a different action.
|
||||
|
||||
What is your next thought or action? Again, you must reply with JSON, and only with JSON.
|
||||
|
||||
%(hint)s
|
||||
"""
|
||||
|
||||
|
||||
def get_hint(latest_action_id: str) -> str:
|
||||
"""Returns action type hint based on given action_id"""
|
||||
hints = {
|
||||
'': "You haven't taken any actions yet. Start by using `ls` to check out what files you're working with.",
|
||||
ActionType.RUN: 'You should think about the command you just ran, what output it gave, and how that affects your plan.',
|
||||
ActionType.READ: 'You should think about the file you just read, what you learned from it, and how that affects your plan.',
|
||||
ActionType.WRITE: 'You just changed a file. You should think about how it affects your plan.',
|
||||
ActionType.BROWSE: 'You should think about the page you just visited, and what you learned from it.',
|
||||
ActionType.MESSAGE: "Look at your last thought in the history above. What does it suggest? Don't think anymore--take action.",
|
||||
ActionType.ADD_TASK: 'You should think about the next action to take.',
|
||||
ActionType.MODIFY_TASK: 'You should think about the next action to take.',
|
||||
ActionType.SUMMARIZE: '',
|
||||
ActionType.FINISH: '',
|
||||
}
|
||||
return hints.get(latest_action_id, '')
|
||||
|
||||
|
||||
def get_prompt_and_images(
|
||||
state: State, max_message_chars: int
|
||||
) -> tuple[str, list[str] | None]:
|
||||
"""Gets the prompt for the planner agent.
|
||||
|
||||
Formatted with the most recent action-observation pairs, current task, and hint based on last action
|
||||
|
||||
Parameters:
|
||||
- state (State): The state of the current agent
|
||||
|
||||
Returns:
|
||||
- str: The formatted string prompt with historical values
|
||||
"""
|
||||
# the plan
|
||||
plan_str = json.dumps(state.root_task.to_dict(), indent=2)
|
||||
|
||||
# the history
|
||||
history_dicts = []
|
||||
latest_action: Action = NullAction()
|
||||
|
||||
# retrieve the latest HISTORY_SIZE events
|
||||
for event_count, event in enumerate(reversed(state.history)):
|
||||
if event_count >= HISTORY_SIZE:
|
||||
break
|
||||
if latest_action == NullAction() and isinstance(event, Action):
|
||||
latest_action = event
|
||||
history_dicts.append(event_to_memory(event, max_message_chars))
|
||||
|
||||
# history_dicts is in reverse order, lets fix it
|
||||
history_dicts.reverse()
|
||||
|
||||
# and get it as a JSON string
|
||||
history_str = json.dumps(history_dicts, indent=2)
|
||||
|
||||
# the plan status
|
||||
current_task = state.root_task.get_current_task()
|
||||
if current_task is not None:
|
||||
plan_status = f"You're currently working on this task:\n{current_task.goal}."
|
||||
if len(current_task.subtasks) == 0:
|
||||
plan_status += "\nIf it's not achievable AND verifiable with a SINGLE action, you MUST break it down into subtasks NOW."
|
||||
else:
|
||||
plan_status = "You're not currently working on any tasks. Your next action MUST be to mark a task as in_progress."
|
||||
|
||||
# the hint, based on the last action
|
||||
hint = get_hint(event_to_memory(latest_action, max_message_chars).get('action', ''))
|
||||
logger.debug('HINT:\n' + hint, extra={'msg_type': 'DETAIL'})
|
||||
|
||||
# the last relevant user message (the task)
|
||||
message, image_urls = state.get_current_user_intent()
|
||||
|
||||
# finally, fill in the prompt
|
||||
return prompt % {
|
||||
'task': message,
|
||||
'plan': plan_str,
|
||||
'history': history_str,
|
||||
'hint': hint,
|
||||
'plan_status': plan_status,
|
||||
}, image_urls
|
||||
|
||||
|
||||
def parse_response(response: str) -> Action:
|
||||
"""Parses the model output to find a valid action to take
|
||||
Parameters:
|
||||
- response (str): A response from the model that potentially contains an Action.
|
||||
|
||||
Returns:
|
||||
- Action: A valid next action to perform from model output
|
||||
"""
|
||||
action_dict = json.loads(response)
|
||||
if 'contents' in action_dict:
|
||||
# The LLM gets confused here. Might as well be robust
|
||||
action_dict['content'] = action_dict.pop('contents')
|
||||
action = action_from_dict(action_dict)
|
||||
return action
|
||||
@@ -0,0 +1,37 @@
|
||||
from openhands.controller.action_parser import ResponseParser
|
||||
from openhands.core.utils import json
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
)
|
||||
from openhands.events.serialization.action import action_from_dict
|
||||
|
||||
|
||||
class PlannerResponseParser(ResponseParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def parse(self, response: str) -> Action:
|
||||
action_str = self.parse_response(response)
|
||||
return self.parse_action(action_str)
|
||||
|
||||
def parse_response(self, response) -> str:
|
||||
# get the next action from the response
|
||||
return response['choices'][0]['message']['content']
|
||||
|
||||
def parse_action(self, action_str: str) -> Action:
|
||||
"""Parses a string to find an action within it
|
||||
|
||||
Parameters:
|
||||
- response (str): The string to be parsed
|
||||
|
||||
Returns:
|
||||
- Action: The action that was found in the response string
|
||||
"""
|
||||
# attempt to load the JSON dict from the response
|
||||
action_dict = json.loads(action_str)
|
||||
|
||||
if 'content' in action_dict:
|
||||
# The LLM gets confused here. Might as well be robust
|
||||
action_dict['contents'] = action_dict.pop('content')
|
||||
|
||||
return action_from_dict(action_dict)
|
||||
@@ -26,6 +26,7 @@ from openhands.events import EventSource, EventStream, EventStreamSubscriber
|
||||
from openhands.events.action import (
|
||||
Action,
|
||||
ActionConfirmationStatus,
|
||||
AddTaskAction,
|
||||
AgentDelegateAction,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
@@ -33,6 +34,7 @@ from openhands.events.action import (
|
||||
CmdRunAction,
|
||||
IPythonRunCellAction,
|
||||
MessageAction,
|
||||
ModifyTaskAction,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
@@ -45,6 +47,7 @@ from openhands.events.observation import (
|
||||
)
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.utils.shutdown_listener import should_continue
|
||||
|
||||
# note: RESUME is only available on web GUI
|
||||
TRAFFIC_CONTROL_REMINDER = (
|
||||
@@ -61,6 +64,7 @@ class AgentController:
|
||||
confirmation_mode: bool
|
||||
agent_to_llm_config: dict[str, LLMConfig]
|
||||
agent_configs: dict[str, AgentConfig]
|
||||
agent_task: asyncio.Future | None = None
|
||||
parent: 'AgentController | None' = None
|
||||
delegate: 'AgentController | None' = None
|
||||
_pending_action: Action | None = None
|
||||
@@ -105,6 +109,7 @@ class AgentController:
|
||||
headless_mode: Whether the agent is run in headless mode.
|
||||
status_callback: Optional callback function to handle status updates.
|
||||
"""
|
||||
self._step_lock = asyncio.Lock()
|
||||
self.id = sid
|
||||
self.agent = agent
|
||||
self.headless_mode = headless_mode
|
||||
@@ -194,45 +199,32 @@ class AgentController:
|
||||
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
|
||||
self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e))
|
||||
|
||||
def step(self):
|
||||
asyncio.create_task(self._step_with_exception_handling())
|
||||
async def start_step_loop(self):
|
||||
"""The main loop for the agent's step-by-step execution."""
|
||||
self.log('info', 'Starting step loop...')
|
||||
while True:
|
||||
if not self._is_awaiting_observation() and not should_continue():
|
||||
break
|
||||
if self._closed:
|
||||
break
|
||||
try:
|
||||
await self._step()
|
||||
except asyncio.CancelledError:
|
||||
self.log('debug', 'AgentController task was cancelled')
|
||||
break
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.log('error', f'Error while running the agent: {e}')
|
||||
await self._react_to_exception(e)
|
||||
|
||||
async def _step_with_exception_handling(self):
|
||||
try:
|
||||
await self._step()
|
||||
except Exception as e:
|
||||
traceback.print_exc()
|
||||
self.log('error', f'Error while running the agent: {e}')
|
||||
reported = RuntimeError(
|
||||
'There was an unexpected error while running the agent.'
|
||||
)
|
||||
if isinstance(e, litellm.AuthenticationError):
|
||||
reported = e
|
||||
await self._react_to_exception(reported)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
def should_step(self, event: Event) -> bool:
|
||||
print('should step?', event)
|
||||
if isinstance(event, Action):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
return True
|
||||
return False
|
||||
if isinstance(event, Observation):
|
||||
if isinstance(event, NullObservation) or isinstance(
|
||||
event, AgentStateChangedObservation
|
||||
):
|
||||
return False
|
||||
return True
|
||||
return False
|
||||
|
||||
def on_event(self, event: Event) -> None:
|
||||
async def on_event(self, event: Event) -> None:
|
||||
"""Callback from the event stream. Notifies the controller of incoming events.
|
||||
|
||||
Args:
|
||||
event (Event): The incoming event to process.
|
||||
"""
|
||||
asyncio.get_event_loop().run_until_complete(self._on_event(event))
|
||||
|
||||
async def _on_event(self, event: Event) -> None:
|
||||
if hasattr(event, 'hidden') and event.hidden:
|
||||
return
|
||||
|
||||
@@ -245,9 +237,6 @@ class AgentController:
|
||||
elif isinstance(event, Observation):
|
||||
await self._handle_observation(event)
|
||||
|
||||
if self.should_step(event):
|
||||
self.step()
|
||||
|
||||
async def _handle_action(self, action: Action) -> None:
|
||||
"""Handles actions from the event stream.
|
||||
|
||||
@@ -260,7 +249,12 @@ class AgentController:
|
||||
await self._handle_message_action(action)
|
||||
elif isinstance(action, AgentDelegateAction):
|
||||
await self.start_delegate(action)
|
||||
|
||||
elif isinstance(action, AddTaskAction):
|
||||
self.state.root_task.add_subtask(
|
||||
action.parent, action.goal, action.subtasks
|
||||
)
|
||||
elif isinstance(action, ModifyTaskAction):
|
||||
self.state.root_task.set_subtask_state(action.task_id, action.state)
|
||||
elif isinstance(action, AgentFinishAction):
|
||||
self.state.outputs = action.outputs
|
||||
self.state.metrics.merge(self.state.local_metrics)
|
||||
@@ -341,28 +335,6 @@ class AgentController:
|
||||
def _reset(self) -> None:
|
||||
"""Resets the agent controller"""
|
||||
|
||||
# make sure there is an Observation with the tool call metadata to be recognized by the agent
|
||||
# otherwise the pending action is found in history, but it's incomplete without an obs with tool result
|
||||
if self._pending_action and hasattr(self._pending_action, 'tool_call_metadata'):
|
||||
# find out if there already is an observation with the same tool call metadata
|
||||
found_observation = False
|
||||
for event in self.state.history:
|
||||
if (
|
||||
isinstance(event, Observation)
|
||||
and event.tool_call_metadata
|
||||
== self._pending_action.tool_call_metadata
|
||||
):
|
||||
found_observation = True
|
||||
break
|
||||
|
||||
# make a new ErrorObservation with the tool call metadata
|
||||
if not found_observation:
|
||||
obs = ErrorObservation(content='The action has not been executed.')
|
||||
obs.tool_call_metadata = self._pending_action.tool_call_metadata
|
||||
obs._cause = self._pending_action.id # type: ignore[attr-defined]
|
||||
self.event_stream.add_event(obs, EventSource.AGENT)
|
||||
|
||||
# reset the pending action, this will be called when the agent is STOPPED or ERROR
|
||||
self._pending_action = None
|
||||
self.agent.reset()
|
||||
|
||||
@@ -493,16 +465,19 @@ class AgentController:
|
||||
async def _step(self) -> None:
|
||||
"""Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget."""
|
||||
if self.get_agent_state() != AgentState.RUNNING:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
if self._pending_action:
|
||||
await asyncio.sleep(1)
|
||||
return
|
||||
|
||||
if self.delegate is not None:
|
||||
assert self.delegate != self
|
||||
# TODO this conditional will always be false, because the parent controllers are unsubscribed
|
||||
# remove if it's still useless when delegation is reworked
|
||||
if self.delegate.get_agent_state() != AgentState.PAUSED:
|
||||
if self.delegate.get_agent_state() == AgentState.PAUSED:
|
||||
# no need to check too often
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
await self._delegate_step()
|
||||
return
|
||||
|
||||
@@ -512,6 +487,7 @@ class AgentController:
|
||||
extra={'msg_type': 'STEP'},
|
||||
)
|
||||
|
||||
# check if agent hit the resources limit
|
||||
stop_step = False
|
||||
if self.state.iteration >= self.state.max_iterations:
|
||||
stop_step = await self._handle_traffic_control(
|
||||
@@ -524,7 +500,6 @@ class AgentController:
|
||||
'budget', current_cost, self.max_budget_per_task
|
||||
)
|
||||
if stop_step:
|
||||
logger.warning('Stopping agent due to traffic control')
|
||||
return
|
||||
|
||||
if self._is_stuck():
|
||||
@@ -536,9 +511,7 @@ class AgentController:
|
||||
self.update_state_before_step()
|
||||
action: Action = NullAction()
|
||||
try:
|
||||
print('STEP AGENT')
|
||||
action = self.agent.step(self.state)
|
||||
print('GOT ACTION', action)
|
||||
if action is None:
|
||||
raise LLMNoActionError('No action was returned')
|
||||
except (
|
||||
@@ -726,20 +699,12 @@ class AgentController:
|
||||
# - the previous session, in which case it has history
|
||||
# - from a parent agent, in which case it has no history
|
||||
# - None / a new state
|
||||
|
||||
# If state is None, we create a brand new state and still load the event stream so we can restore the history
|
||||
if state is None:
|
||||
self.state = State(
|
||||
inputs={},
|
||||
max_iterations=max_iterations,
|
||||
confirmation_mode=confirmation_mode,
|
||||
)
|
||||
self.state.start_id = 0
|
||||
|
||||
self.log(
|
||||
'debug',
|
||||
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
|
||||
)
|
||||
else:
|
||||
self.state = state
|
||||
|
||||
@@ -751,8 +716,7 @@ class AgentController:
|
||||
f'AgentController {self.id} initializing history from event {self.state.start_id}',
|
||||
)
|
||||
|
||||
# Always load from the event stream to avoid losing history
|
||||
self._init_history()
|
||||
self._init_history()
|
||||
|
||||
def _init_history(self) -> None:
|
||||
"""Initializes the agent's history from the event stream.
|
||||
@@ -981,7 +945,7 @@ class AgentController:
|
||||
return (
|
||||
f'AgentController(id={self.id}, agent={self.agent!r}, '
|
||||
f'event_stream={self.event_stream!r}, '
|
||||
f'state={self.state!r}, '
|
||||
f'state={self.state!r}, agent_task={self.agent_task!r}, '
|
||||
f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})'
|
||||
)
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ def display_event(event: Event, config: AppConfig):
|
||||
display_confirmation(event.confirmation_state)
|
||||
|
||||
|
||||
async def main(loop):
|
||||
async def main():
|
||||
"""Runs the agent in CLI mode"""
|
||||
|
||||
parser = get_parser()
|
||||
@@ -112,7 +112,7 @@ async def main(loop):
|
||||
|
||||
logger.setLevel(logging.WARNING)
|
||||
config = load_app_config(config_file=args.config_file)
|
||||
sid = str(uuid4())
|
||||
sid = 'cli'
|
||||
|
||||
agent_cls: Type[Agent] = Agent.get_cls(config.default_agent)
|
||||
agent_config = config.get_agent_config(config.default_agent)
|
||||
@@ -150,6 +150,7 @@ async def main(loop):
|
||||
|
||||
async def prompt_for_next_task():
|
||||
# Run input() in a thread pool to avoid blocking the event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
next_message = await loop.run_in_executor(
|
||||
None, lambda: input('How can I help? >> ')
|
||||
)
|
||||
@@ -164,12 +165,13 @@ async def main(loop):
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
|
||||
async def prompt_for_user_confirmation():
|
||||
loop = asyncio.get_event_loop()
|
||||
user_confirmation = await loop.run_in_executor(
|
||||
None, lambda: input('Confirm action (possible security risk)? (y/n) >> ')
|
||||
)
|
||||
return user_confirmation.lower() == 'y'
|
||||
|
||||
async def on_event_async(event: Event):
|
||||
async def on_event(event: Event):
|
||||
display_event(event, config)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state in [
|
||||
@@ -191,9 +193,6 @@ async def main(loop):
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED), EventSource.USER
|
||||
)
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4()))
|
||||
|
||||
await runtime.connect()
|
||||
@@ -209,7 +208,7 @@ if __name__ == '__main__':
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
loop.run_until_complete(main(loop))
|
||||
loop.run_until_complete(main())
|
||||
except KeyboardInterrupt:
|
||||
print('Received keyboard interrupt, shutting down...')
|
||||
except ConnectionRefusedError as e:
|
||||
|
||||
@@ -385,7 +385,7 @@ def get_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument(
|
||||
'-n',
|
||||
'--name',
|
||||
default='',
|
||||
default='default',
|
||||
type=str,
|
||||
help='Name for the session',
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ async def run_agent_until_done(
|
||||
the agent until it reaches a terminal state.
|
||||
Note that runtime must be connected before being passed in here.
|
||||
"""
|
||||
controller.agent_task = asyncio.create_task(controller.start_step_loop())
|
||||
|
||||
def status_callback(msg_type, msg_id, msg):
|
||||
if msg_type == 'error':
|
||||
@@ -40,3 +41,10 @@ async def run_agent_until_done(
|
||||
|
||||
while controller.state.agent_state not in end_states:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if not controller.agent_task.done():
|
||||
controller.agent_task.cancel()
|
||||
try:
|
||||
await controller.agent_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
@@ -182,7 +182,7 @@ async def run_controller(
|
||||
# init with the provided actions
|
||||
event_stream.add_event(initial_user_action, EventSource.USER)
|
||||
|
||||
def on_event(event: Event):
|
||||
async def on_event(event: Event):
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state == AgentState.AWAITING_USER_INPUT:
|
||||
if exit_on_message:
|
||||
|
||||
@@ -62,6 +62,10 @@ class ActionTypeSchema(BaseModel):
|
||||
|
||||
SUMMARIZE: str = Field(default='summarize')
|
||||
|
||||
ADD_TASK: str = Field(default='add_task')
|
||||
|
||||
MODIFY_TASK: str = Field(default='modify_task')
|
||||
|
||||
PAUSE: str = Field(default='pause')
|
||||
"""Pauses the task.
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user