Compare commits

..

33 Commits

Author SHA1 Message Date
openhands 2819b626a4 Fix unit tests for login workflow changes 2025-04-24 18:35:51 +00:00
openhands a3daf4735e Fix hooks to avoid Router context requirement 2025-04-24 07:01:57 +00:00
openhands ff36cdf30e Prevent background API calls when on TOS page 2025-04-24 06:43:56 +00:00
openhands fa0044e821 Fix login flow to always start OIDC before TOS check 2025-04-24 04:30:15 +00:00
openhands 536588dd19 Fix loading state in TOS acceptance button 2025-04-24 04:03:47 +00:00
openhands a10dfffe0f Fix axios import in accept-tos.tsx 2025-04-24 03:58:22 +00:00
openhands aaaad02042 Add react-toastify dependency 2025-04-24 03:56:33 +00:00
openhands 17d55eab72 Update TOS acceptance to use database storage instead of localStorage 2025-04-24 03:34:57 +00:00
openhands bf9f953bea Move Terms of Service acceptance to dedicated page 2025-04-23 19:44:04 +00:00
Rohit Malhotra 964478c22f [Feat]: Custom secrets plumbing for BE (#7891)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 12:44:25 -04:00
Robert Brennan 00c449d447 Add loading indicator to repository dropdown on home page (#8015)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 12:27:49 -04:00
Rohit Malhotra bfd75a1355 (Chore): Rm legacy resolver code (#8001) 2025-04-23 11:34:03 -04:00
Graham Neubig dc91cb263b Add extensive typing to controller directory (#7731)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 15:33:17 +00:00
Robert Brennan fa559ace86 Add API keys management UI to settings page (#7710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 19:08:32 +04:00
mamoodi 1fd26d196a Release 0.34.0 (#8011) 2025-04-23 11:06:28 -04:00
Robert Brennan 5de62d85fd add an option for a headless backend (#8032) 2025-04-23 04:09:22 +02:00
Chase 5d749aeba7 replace erroneous rstrip() with removesuffix() (#8024) 2025-04-22 22:50:14 +00:00
Chase 693c72d670 remove sse subsection accessor of McpConfig in action_execution_client (#8021) 2025-04-23 00:40:10 +02:00
Engel Nyst 62557d44f2 Use short tool descriptions for o4-mini (#8022) 2025-04-23 00:30:21 +02:00
Robert Brennan 89f8e162da Fix: Don't show status indicator for command timeouts (#8012)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 23:30:07 +04:00
Robert Brennan b0a9938e6c Always run git init in SaaS mode regardless of workspace_base setting (#8014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 17:43:20 +00:00
Rohit Malhotra 039fe295a4 Add RateLimitError and handle rate limiting in GitLab and GitHub services (#8003)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-04-22 16:30:41 +00:00
dependabot[bot] 8f3ff1210e chore(deps): bump the version-all group with 6 updates (#8009)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 15:16:41 +00:00
Peter Hamilton 5e1e685493 fix: Use SANDBOX_BASE_CONTAINER_IMAGE in resolver workflow (#7956)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 16:15:49 +02:00
sp.wack e9f2b72ea5 chore: Better home screen (#7784)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-22 13:15:41 +00:00
Rohit Malhotra 986b90be0a [Fix]: fetch latest token when existing token doesn't exist (#8000) 2025-04-22 03:22:48 +00:00
Robert Brennan bf9f2aa7a5 Initialize git repo in workspace when no GitHub repo is selected (#7904)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 18:34:42 -04:00
Engel Nyst b3bd3924a0 Fix and simplify local runtime init (#7997) 2025-04-22 00:24:22 +02:00
Rohit Malhotra 0de50153a0 Add HTTP method option to Git service fetch_data functions (#7996)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 18:15:05 -04:00
Xingyao Wang a04024a239 refactor: file viewer server so it is accessible via localhost without authentication (#7987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 22:12:06 +00:00
Rohit Malhotra 1e509a70d4 [Fix]: Dedup token verification logic in resolver (#7967)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 20:34:29 +00:00
Engel Nyst 300a59853b Quick fix local runtime (#7991) 2025-04-21 20:28:30 +00:00
Rohit Malhotra 2514b200c5 Fix dictionary changed size during iteration error in EventStream (#7984)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-21 16:21:30 -04:00
135 changed files with 4876 additions and 3207 deletions
+1 -1
View File
@@ -179,7 +179,7 @@ jobs:
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.PAT_TOKEN || github.token }}" >> $GITHUB_ENV
echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
# Set branch variables
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
+1 -1
View File
@@ -118,7 +118,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.33-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.34-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -52,17 +52,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
+1 -1
View File
@@ -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.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.34-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
- 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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
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.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
@@ -34,7 +34,7 @@ Docker で OpenHands を CLI モードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -44,7 +44,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -31,7 +31,7 @@ DockerでOpenHandsをヘッドレスモードで実行するには:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -42,7 +42,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
@@ -25,7 +25,7 @@ nikolaik の `SANDBOX_RUNTIME_CONTAINER_IMAGE` は、ランタイムサーバー
```bash
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
@@ -82,5 +82,5 @@ docker network create openhands-network
# 分離されたネットワークで OpenHands を実行
docker run # ... \
--network openhands-network \
docker.all-hands.dev/all-hands-ai/openhands:0.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
@@ -35,7 +35,7 @@ Para executar o OpenHands no modo CLI com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -32,7 +32,7 @@ Para executar o OpenHands no modo Headless com Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "escreva um script bash que imprima oi"
```
@@ -58,17 +58,17 @@
A maneira mais fácil de executar o OpenHands é no Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
Você encontrará o OpenHands em execução em http://localhost:3000!
@@ -13,7 +13,7 @@ Este é o Runtime padrão que é usado quando você inicia o OpenHands. Você po
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
@@ -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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
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.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
你也可以在可脚本化的[无头模式](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.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
+2 -2
View File
@@ -35,7 +35,7 @@ To run OpenHands in CLI mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -45,7 +45,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.cli
```
+2 -2
View File
@@ -32,7 +32,7 @@ To run OpenHands in Headless mode with Docker:
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -43,7 +43,7 @@ docker run -it \
-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.33 \
docker.all-hands.dev/all-hands-ai/openhands:0.34 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+3 -3
View File
@@ -58,17 +58,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
The easiest way to run OpenHands is in Docker.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.34-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.33-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.34-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.33
docker.all-hands.dev/all-hands-ai/openhands:0.34
```
You'll find OpenHands running at http://localhost:3000!
-2098
View File
File diff suppressed because it is too large Load Diff
@@ -45,7 +45,7 @@ describe("Empty state", () => {
it("should render suggestions if empty", () => {
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -76,7 +76,7 @@ describe("Empty state", () => {
it("should render the default suggestions", () => {
renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -114,7 +114,7 @@ describe("Empty state", () => {
const user = userEvent.setup();
const { store } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -151,7 +151,7 @@ describe("Empty state", () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(<ChatInterface />, {
preloadedState: {
chat: {
chat: {
messages: [],
systemMessage: {
content: "",
@@ -95,6 +95,23 @@ describe("ExpandableMessage", () => {
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});
it("should render with neutral border and no icon for action messages with undefined success (timeout case)", () => {
renderWithProviders(
<ExpandableMessage
id="OBSERVATION_MESSAGE$RUN"
message="Command timed out"
type="action"
success={undefined}
/>,
);
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
const container = element.closest(
"div.flex.gap-2.items-center.justify-start",
);
expect(container).toHaveClass("border-neutral-300");
expect(screen.queryByTestId("status-icon")).not.toBeInTheDocument();
});
it("should render the out of credits message when the user is out of credits", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
@@ -1,12 +1,16 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeAll, afterAll } from "vitest";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { AuthModal } from "#/components/features/waitlist/auth-modal";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as AuthHook from "#/context/auth-context";
// Mock the useAuthUrl hook
vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: () => "https://gitlab.com/oauth/authorize"
}));
describe("AuthModal", () => {
beforeAll(() => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
vi.spyOn(AuthHook, "useAuth").mockReturnValue({
providersAreSet: false,
@@ -16,50 +20,29 @@ describe("AuthModal", () => {
});
});
afterAll(() => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
vi.resetAllMocks();
});
it("should render a tos checkbox that is unchecked by default", () => {
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).not.toBeChecked();
});
it("should only enable the identity provider buttons if the tos checkbox is checked", async () => {
const user = userEvent.setup();
render(<AuthModal githubAuthUrl={null} appMode="saas" />);
const checkbox = screen.getByRole("checkbox");
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" });
expect(githubButton).toBeDisabled();
expect(gitlabButton).toBeDisabled();
await user.click(checkbox);
expect(githubButton).not.toBeDisabled();
expect(gitlabButton).not.toBeDisabled();
expect(githubButton).toBeInTheDocument();
expect(gitlabButton).toBeInTheDocument();
});
it("should set user analytics consent to true when the user checks the tos checkbox", async () => {
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const mockUrl = "https://github.com/login/oauth/authorize";
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
await user.click(githubButton);
const button = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" });
await user.click(button);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(window.location.href).toBe(mockUrl);
});
});
@@ -0,0 +1,70 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { AuthProvider } from "#/context/auth-context";
import { HomeHeader } from "#/components/features/home/home-header";
import OpenHands from "#/api/open-hands";
const renderHomeHeader = () => {
const RouterStub = createRoutesStub([
{
Component: HomeHeader,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("HomeHeader", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderHomeHeader();
const launchButton = screen.getByRole("button", {
name: /launch from scratch/i,
});
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
[],
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderHomeHeader();
const launchButton = screen.getByRole("button", {
name: /launch from scratch/i,
});
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
});
@@ -0,0 +1,229 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git";
import * as GitService from "#/api/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
const renderRepoConnector = (initialProvidersAreSet = true) => {
const mockRepoSelection = vi.fn();
const RouterStub = createRoutesStub([
{
Component: () => <RepoConnector onRepoSelection={mockRepoSelection} />,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
full_name: "rbren/polaris",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "All-Hands-AI/OpenHands",
git_provider: "github",
is_public: true,
},
];
describe("RepoConnector", () => {
it("should render the repository connector section", () => {
renderRepoConnector();
screen.getByTestId("repo-connector");
});
it("should render the available repositories in the dropdown", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await waitFor(() => {
screen.getByText("rbren/polaris");
screen.getByText("All-Hands-AI/OpenHands");
});
});
it("should only enable the launch button if a repo is selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
expect(launchButton).toBeEnabled();
});
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderRepoConnector();
await screen.findByText("Add GitHub repos");
});
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderRepoConnector();
expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument();
expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument();
});
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const repoConnector = screen.getByTestId("repo-connector");
const launchButton =
within(repoConnector).getByTestId("repo-launch-button");
await userEvent.click(launchButton);
// repo not selected yet
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown")
);
await userEvent.click(dropdown);
const repoOption = screen.getByText("rbren/polaris");
await userEvent.click(repoOption);
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
{
full_name: "rbren/polaris",
git_provider: "github",
id: 1,
is_public: true,
},
undefined,
[],
undefined,
);
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
await userEvent.click(screen.getByText("rbren/polaris"));
await userEvent.click(launchButton);
expect(launchButton).toBeDisabled();
expect(launchButton).toHaveTextContent(/Loading/i);
});
it("should not display a button to settings if the user is signed in with their git provider", async () => {
renderRepoConnector(true);
expect(
screen.queryByTestId("navigate-to-settings-button"),
).not.toBeInTheDocument();
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
renderRepoConnector(false);
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
const dropdown = screen.queryByTestId("repo-dropdown");
const launchButton = screen.queryByTestId("repo-launch-button");
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
expect(dropdown).not.toBeInTheDocument();
expect(launchButton).not.toBeInTheDocument();
expect(providerLinks.length).toBe(0);
expect(goToSettingsButton).toBeInTheDocument();
await userEvent.click(goToSettingsButton);
await screen.findByTestId("settings-screen");
});
});
@@ -0,0 +1,186 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import * as GitService from "#/api/git";
import { GitRepository } from "#/types/git";
import {
getFailingChecksPrompt,
getMergeConflictPrompt,
getOpenIssuePrompt,
getUnresolvedCommentsPrompt,
} from "#/components/features/home/tasks/get-prompt-for-query";
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
};
const MOCK_TASK_2: SuggestedTask = {
issue_number: 456,
repo: "repo2",
title: "Task 2",
task_type: "FAILING_CHECKS",
};
const MOCK_TASK_3: SuggestedTask = {
issue_number: 789,
repo: "repo3",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
};
const MOCK_TASK_4: SuggestedTask = {
issue_number: 101112,
repo: "repo4",
title: "Task 4",
task_type: "OPEN_ISSUE",
};
const MOCK_RESPOSITORIES: GitRepository[] = [
{ id: 1, full_name: "repo1", git_provider: "github", is_public: true },
{ id: 2, full_name: "repo2", git_provider: "github", is_public: true },
{ id: 3, full_name: "repo3", git_provider: "gitlab", is_public: true },
{ id: 4, full_name: "repo4", git_provider: "gitlab", is_public: true },
];
const renderTaskCard = (task = MOCK_TASK_1) => {
const RouterStub = createRoutesStub([
{
Component: () => <TaskCard task={task} />,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("TaskCard", () => {
it("format the issue id", async () => {
renderTaskCard();
const taskId = screen.getByTestId("task-id");
expect(taskId).toHaveTextContent(/#123/i);
});
it("should call createConversation when clicking the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalled();
});
describe("creating conversation prompts", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
it("should call create conversation with the merge conflict prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_1);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[0],
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
[],
undefined,
);
});
it("should call create conversation with the failing checks prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_2);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[1],
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
[],
undefined,
);
});
it("should call create conversation with the unresolved comments prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_3);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[2],
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
[],
undefined,
);
});
it("should call create conversation with the open issue prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_4);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[3],
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
[],
undefined,
);
});
});
it("should disable the launch button and update text content when creating a conversation", async () => {
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
});
@@ -0,0 +1,113 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import userEvent from "@testing-library/user-event";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import { AuthProvider } from "#/context/auth-context";
const renderTaskSuggestions = (initialProvidersAreSet = true) => {
const RouterStub = createRoutesStub([
{
Component: TaskSuggestions,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
};
describe("TaskSuggestions", () => {
const getSuggestedTasksSpy = vi.spyOn(
SuggestionsService,
"getSuggestedTasks",
);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the task suggestions section", () => {
renderTaskSuggestions();
screen.getByTestId("task-suggestions");
});
it("should render an empty message if there are no tasks", async () => {
getSuggestedTasksSpy.mockResolvedValue([]);
renderTaskSuggestions();
await screen.findByText(/No tasks available/i);
});
it("should render the task groups with the correct titles", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
await waitFor(() => {
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
});
it("should render the task cards with the correct task details", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
await waitFor(() => {
MOCK_TASKS.forEach((task) => {
screen.getByText(task.title);
});
});
});
it("should render skeletons when loading", async () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions();
const skeletons = screen.getAllByTestId("task-group-skeleton");
expect(skeletons.length).toBeGreaterThan(0);
await waitFor(() => {
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
renderTaskSuggestions(false);
expect(getSuggestedTasksSpy).not.toHaveBeenCalled();
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
expect(goToSettingsButton).toBeInTheDocument();
await userEvent.click(goToSettingsButton);
await screen.findByTestId("settings-screen");
});
});
@@ -0,0 +1,108 @@
import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import { openHands } from "#/api/open-hands-axios";
// Mock the react-router hooks
vi.mock("react-router", () => ({
useNavigate: () => vi.fn(),
useSearchParams: () => [
{
get: (param: string) => {
if (param === "redirect_url") {
return "/dashboard";
}
return null;
},
},
],
}));
// Mock the axios instance
vi.mock("#/api/open-hands-axios", () => ({
openHands: {
post: vi.fn(),
},
}));
describe("AcceptTOS", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.resetAllMocks();
});
it("should render a TOS checkbox that is unchecked by default", () => {
render(<AcceptTOS />);
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
expect(checkbox).not.toBeChecked();
expect(continueButton).toBeDisabled();
});
it("should enable the continue button when the TOS checkbox is checked", async () => {
const user = userEvent.setup();
render(<AcceptTOS />);
const checkbox = screen.getByRole("checkbox");
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
expect(continueButton).toBeDisabled();
await user.click(checkbox);
expect(continueButton).not.toBeDisabled();
});
it("should set user analytics consent to true when the user accepts TOS", async () => {
const handleCaptureConsentSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
// Mock the API response
vi.mocked(openHands.post).mockResolvedValue({
data: { redirect_url: "/dashboard" },
});
const user = userEvent.setup();
render(<AcceptTOS />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
await user.click(continueButton);
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
redirect_url: "/dashboard",
});
});
it("should handle external redirect URLs", async () => {
// Mock the API response with an external URL
const externalUrl = "https://example.com/callback";
vi.mocked(openHands.post).mockResolvedValue({
data: { redirect_url: externalUrl },
});
const user = userEvent.setup();
render(<AcceptTOS />);
const checkbox = screen.getByRole("checkbox");
await user.click(checkbox);
const continueButton = screen.getByRole("button", { name: "TOS$CONTINUE" });
await user.click(continueButton);
expect(window.location.href).toBe(externalUrl);
});
});
@@ -0,0 +1,370 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { Provider } from "react-redux";
import { setupStore } from "test-utils";
import { AxiosError } from "axios";
import HomeScreen from "#/routes/home";
import { AuthProvider } from "#/context/auth-context";
import * as GitService from "#/api/git";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
import MainApp from "#/routes/root-layout";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
const RouterStub = createRoutesStub([
{
Component: MainApp,
path: "/",
children: [
{
Component: HomeScreen,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
{
Component: () => <div data-testid="settings-screen" />,
path: "/settings",
},
],
},
]);
const renderHomeScreen = (initialProvidersAreSet = true) =>
render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
</Provider>
),
});
const MOCK_RESPOSITORIES: GitRepository[] = [
{
id: 1,
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
},
];
describe("HomeScreen", () => {
it("should render", () => {
renderHomeScreen();
screen.getByTestId("home-screen");
});
it("should render the repository connector and suggested tasks sections", async () => {
renderHomeScreen();
screen.getByTestId("repo-connector");
screen.getByTestId("task-suggestions");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should reset the filtered tasks when the selected repository is cleared", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Clear the selected repository
await userEvent.clear(dropdown);
// All tasks should be visible again
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
let headerLaunchButton = screen.getByTestId("header-launch-button");
let repoLaunchButton = screen.getByTestId("repo-launch-button");
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Select a repository from the dropdown to enable the repo launch button
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
return {
headerLaunchButton,
repoLaunchButton,
tasksLaunchButtons,
};
};
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
it("should disable the other launch buttons when the header launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the header button is clicked
await userEvent.click(headerLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
it("should disable the other launch buttons when the repo launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the repo button is clicked
await userEvent.click(repoLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
it("should disable the other launch buttons when any task launch button is clicked", async () => {
renderHomeScreen();
const { headerLaunchButton, repoLaunchButton, tasksLaunchButtons } =
await setupLaunchButtons();
const tasksLaunchButtonsAfter =
await screen.findAllByTestId("task-launch-button");
// All other buttons should be disabled when the task button is clicked
await userEvent.click(tasksLaunchButtons[0]);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => {
renderHomeScreen(false);
const taskSuggestions = screen.queryByTestId("task-suggestions");
const repoConnector = screen.getByTestId("repo-connector");
expect(taskSuggestions).not.toBeInTheDocument();
expect(repoConnector).toBeInTheDocument();
});
});
describe("Settings 404", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderHomeScreen();
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderHomeScreen();
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);
expect(setupPaymentModal).toBeInTheDocument();
});
});
-177
View File
@@ -1,177 +0,0 @@
import { createRoutesStub } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import { screen } from "@testing-library/react";
import { AxiosError } from "axios";
import MainApp from "#/routes/root-layout";
import SettingsScreen from "#/routes/settings";
import Home from "#/routes/home";
import OpenHands from "#/api/open-hands";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const RouterStub = createRoutesStub([
{
// layout route
Component: MainApp,
path: "/",
children: [
{
// home route
Component: Home,
path: "/",
},
{
Component: SettingsScreen,
path: "/settings",
},
],
},
]);
afterEach(() => {
vi.clearAllMocks();
});
describe("Home Screen", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
it("should render the home screen", () => {
renderWithProviders(<RouterStub initialEntries={["/"]} />);
});
it("should navigate to the settings screen when the settings button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsButton = await screen.findByTestId("settings-button");
await user.click(settingsButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const connectToGitHubButton =
await screen.findByTestId("connect-to-github");
await user.click(connectToGitHubButton);
const settingsScreen = await screen.findByTestId("settings-screen");
expect(settingsScreen).toBeInTheDocument();
});
});
describe("Settings 404", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
it("should open the settings modal if GET /settings fails with a 404", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
});
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
const user = userEvent.setup();
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const settingsScreen = screen.queryByTestId("settings-screen");
expect(settingsScreen).not.toBeInTheDocument();
const settingsModal = await screen.findByTestId("ai-config-modal");
expect(settingsModal).toBeInTheDocument();
const advancedSettingsButton = await screen.findByTestId(
"advanced-settings-link",
);
await user.click(advancedSettingsButton);
const settingsScreenAfter = await screen.findByTestId("settings-screen");
expect(settingsScreenAfter).toBeInTheDocument();
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
expect(settingsModalAfter).not.toBeInTheDocument();
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// @ts-expect-error - we only need APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
// small hack to wait for the modal to not appear
await expect(
screen.findByTestId("ai-config-modal", {}, { timeout: 1000 }),
).rejects.toThrow();
});
});
describe("Setup Payment modal", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
afterEach(() => {
vi.resetAllMocks();
});
it("should only render if SaaS mode and is new user", async () => {
// @ts-expect-error - we only need the APP_MODE for this test
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);
expect(setupPaymentModal).toBeInTheDocument();
});
});
@@ -43,10 +43,12 @@ describe("Settings Billing", () => {
renderSettingsScreen();
await waitFor(() => {
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
it("should render the navbar if SaaS mode", async () => {
@@ -0,0 +1,91 @@
import { expect, test } from "vitest";
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
const rawTasks: SuggestedTask[] = [
{
issue_number: 1,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
},
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
},
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
},
];
const groupedTasks: SuggestedTaskGroup[] = [
{
title: "repo1",
tasks: [
{
issue_number: 1,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
},
],
},
{
title: "repo2",
tasks: [
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
},
],
},
{
title: "repo3",
tasks: [
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
},
],
},
];
test("groupSuggestedTasks", () => {
expect(groupSuggestedTasks(rawTasks)).toEqual(groupedTasks);
});
+16 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"dependencies": {
"@heroui/react": "2.7.6",
"@microlink/react-json-view": "^1.26.1",
@@ -44,6 +44,7 @@
"react-router": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^11.0.5",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
@@ -15444,6 +15445,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-toastify": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz",
"integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1"
},
"peerDependencies": {
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.33.0",
"version": "0.34.0",
"private": true,
"type": "module",
"engines": {
@@ -43,6 +43,7 @@
"react-router": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"react-toastify": "^11.0.5",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
@@ -105,6 +105,7 @@ function isRawTranslationKey(str) {
// Specific technical strings that should be excluded from localization
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
];
function isExcludedTechnicalString(str) {
+49
View File
@@ -0,0 +1,49 @@
import { openHands } from "./open-hands-axios";
export interface ApiKey {
id: string;
name: string;
prefix: string;
created_at: string;
last_used_at: string | null;
}
export interface CreateApiKeyResponse {
id: string;
name: string;
key: string; // Full key, only returned once upon creation
prefix: string;
created_at: string;
}
class ApiKeysClient {
/**
* Get all API keys for the current user
*/
static async getApiKeys(): Promise<ApiKey[]> {
const { data } = await openHands.get<unknown>("/api/keys");
// Ensure we always return an array, even if the API returns something else
return Array.isArray(data) ? (data as ApiKey[]) : [];
}
/**
* Create a new API key
* @param name - A descriptive name for the API key
*/
static async createApiKey(name: string): Promise<CreateApiKeyResponse> {
const { data } = await openHands.post<CreateApiKeyResponse>("/api/keys", {
name,
});
return data;
}
/**
* Delete an API key
* @param id - The ID of the API key to delete
*/
static async deleteApiKey(id: string): Promise<void> {
await openHands.delete(`/api/keys/${id}`);
}
}
export default ApiKeysClient;
@@ -0,0 +1,9 @@
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { openHands } from "../open-hands-axios";
export class SuggestionsService {
static async getSuggestedTasks(): Promise<SuggestedTask[]> {
const { data } = await openHands.get("/api/user/suggested-tasks");
return data;
}
}
@@ -15,13 +15,13 @@
fill="black" />
<path
d="M38.7381 10.5084C38.5759 10.5084 38.4106 10.4788 38.2545 10.4076C37.6821 10.1526 37.4312 9.49736 37.6944 8.94289C38.5453 7.1431 39.791 5.48266 41.2938 4.14245C41.7559 3.73031 42.4782 3.75699 42.9037 4.20768C43.3291 4.65541 43.3016 5.35516 42.8363 5.76731C41.5539 6.91182 40.4919 8.32912 39.7634 9.86502C39.5737 10.2653 39.1666 10.5055 38.7381 10.5084Z"
fill="white" />
fill="black" />
<path
d="M34.898 9.87074C34.3073 9.87667 33.8023 9.43784 33.7533 8.85669C33.536 6.25633 33.5268 3.62039 33.7319 1.02003C33.7808 0.412188 34.3287 -0.0414663 34.9531 0.00300963C35.5805 0.0504507 36.0488 0.578232 36.0029 1.18607C35.807 3.67079 35.8162 6.1911 36.0243 8.67582C36.0763 9.28366 35.6081 9.81737 34.9806 9.86481C34.9531 9.86481 34.9255 9.86778 34.898 9.86778V9.87074Z"
fill="white" />
fill="black" />
<path
d="M30.976 10.5558C30.4649 10.5618 29.9935 10.2267 29.8619 9.7256C29.3783 7.88726 28.4632 6.14084 27.2175 4.67906C26.8165 4.20762 26.8869 3.51379 27.3705 3.12537C27.8572 2.73695 28.5734 2.80514 28.9743 3.27362C30.4312 4.98743 31.5024 7.03036 32.0656 9.18003C32.2217 9.77008 31.8514 10.372 31.2423 10.5232C31.1505 10.5469 31.0617 10.5558 30.9699 10.5588L30.976 10.5558Z"
fill="white" />
fill="black" />
</g>
</g>
<defs>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -0,0 +1,21 @@
import { Link } from "react-router";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { useSettings } from "#/hooks/query/use-settings";
export function ConnectToProviderMessage() {
const { isLoading } = useSettings();
const { t } = useTranslation();
return (
<div className="flex flex-col gap-4">
<p>{t("HOME$CONNECT_PROVIDER_MESSAGE")}</p>
<Link data-testid="navigate-to-settings-button" to="/settings">
<BrandButton type="button" variant="primary" isDisabled={isLoading}>
{!isLoading && t("SETTINGS$TITLE")}
{isLoading && t("HOME$LOADING")}
</BrandButton>
</Link>
</div>
);
}
@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { BrandButton } from "../settings/brand-button";
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
export function HomeHeader() {
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
return (
<header className="flex flex-col gap-5">
<AllHandsLogo />
<div className="flex items-center justify-between">
<h1 className="heading">{t("HOME$LETS_START_BUILDING")}</h1>
<BrandButton
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({})}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && "Launch from Scratch"}
{isCreatingConversation && t("HOME$LOADING")}
</BrandButton>
</div>
<div className="flex items-center justify-between">
<p className="text-sm max-w-[424px]">
{t("HOME$OPENHANDS_DESCRIPTION")}
</p>
<p className="text-sm">
{t("HOME$NOT_SURE_HOW_TO_START")}{" "}
<a
href="https://docs.all-hands.dev/modules/usage/getting-started"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2"
>
Read this
</a>
</p>
</div>
</header>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { useAuth } from "#/context/auth-context";
import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links";
interface RepoConnectorProps {
onRepoSelection: (repoTitle: string | null) => void;
}
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const { t } = useTranslation();
const isSaaS = config?.APP_MODE === "saas";
return (
<section
data-testid="repo-connector"
className="w-full flex flex-col gap-6"
>
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
{!providersAreSet && <ConnectToProviderMessage />}
{providersAreSet && (
<RepositorySelectionForm onRepoSelection={onRepoSelection} />
)}
{isSaaS && providersAreSet && <RepoProviderLinks />}
</section>
);
}
@@ -0,0 +1,17 @@
import { useConfig } from "#/hooks/query/use-config";
export function RepoProviderLinks() {
const { data: config } = useConfig();
const githubHref = config
? `https://github.com/apps/${config.APP_SLUG}/installations/new`
: "";
return (
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
<a href={githubHref} target="_blank" rel="noopener noreferrer">
Add GitHub repos
</a>
</div>
);
}
@@ -0,0 +1,138 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: { pages: [{ data: [] }] },
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
// Mock the modules
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
describe("RepositorySelectionForm", () => {
const mockOnRepoSelection = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("shows loading indicator when repositories are being fetched", () => {
// Setup loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
test("shows dropdown when repositories are loaded", () => {
// Setup loaded repositories
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
],
},
],
},
isLoading: false,
isError: false,
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
});
test("shows error message when repository fetch fails", () => {
// Setup error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("Failed to fetch repositories"),
});
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
});
@@ -0,0 +1,152 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
// Loading state component
function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}
// Error state component
function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}
// Repository dropdown component
interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
}
function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
/>
);
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
mutate: createConversation,
isPending,
isSuccess,
} = useCreateConversation();
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const repositoriesItems = repositoriesList?.map((repo) => ({
key: repo.id,
label: repo.full_name,
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = repositoriesList?.find(
(repo) => repo.id.toString() === key,
);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);
};
const handleInputChange = (value: string) => {
if (value === "") {
setSelectedRepository(null);
onRepoSelection(null);
}
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
return <RepositoryLoadingState />;
}
if (isRepositoriesError) {
return <RepositoryErrorState />;
}
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleInputChange}
/>
);
};
return (
<>
{renderRepositorySelector()}
<BrandButton
testId="repo-launch-button"
variant="primary"
type="button"
isDisabled={
!selectedRepository ||
isCreatingConversation ||
isLoadingRepositories ||
isRepositoriesError
}
onClick={() => createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}
{isCreatingConversation && t("HOME$LOADING")}
</BrandButton>
</>
);
}
@@ -0,0 +1,49 @@
import { SuggestedTaskType } from "./task.types";
export const getMergeConflictPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
export const getFailingChecksPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`;
export const getUnresolvedCommentsPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
export const getOpenIssuePrompt = (
issueNumber: number,
repo: string,
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made
Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`;
export const getPromptForQuery = (
type: SuggestedTaskType,
issueNumber: number,
repo: string,
) => {
switch (type) {
case "MERGE_CONFLICTS":
return getMergeConflictPrompt(issueNumber, repo);
case "FAILING_CHECKS":
return getFailingChecksPrompt(issueNumber, repo);
case "UNRESOLVED_COMMENTS":
return getUnresolvedCommentsPrompt(issueNumber, repo);
case "OPEN_ISSUE":
return getOpenIssuePrompt(issueNumber, repo);
default:
return "";
}
};
@@ -0,0 +1,79 @@
import { useTranslation } from "react-i18next";
import { SuggestedTask } from "./task.types";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { cn } from "#/utils/utils";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { getPromptForQuery } from "./get-prompt-for-query";
import { TaskIssueNumber } from "./task-issue-number";
const getTaskTypeMap = (
t: (key: string) => string,
): Record<SuggestedTask["task_type"], string> => ({
FAILING_CHECKS: t("HOME$FIX_FAILING_CHECKS"),
MERGE_CONFLICTS: t("HOME$RESOLVE_MERGE_CONFLICTS"),
OPEN_ISSUE: t("HOME$OPEN_ISSUE"),
UNRESOLVED_COMMENTS: t("HOME$RESOLVE_UNRESOLVED_COMMENTS"),
});
interface TaskCardProps {
task: SuggestedTask;
}
export function TaskCard({ task }: TaskCardProps) {
const { data: repositories } = useUserRepositories();
const { mutate: createConversation, isPending } = useCreateConversation();
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const getRepo = (repo: string) => {
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const selectedRepo = repositoriesList?.find(
(repository) => repository.full_name === repo,
);
return selectedRepo;
};
const handleLaunchConversation = () => {
const repo = getRepo(task.repo);
const query = getPromptForQuery(
task.task_type,
task.issue_number,
task.repo,
);
return createConversation({
selectedRepository: repo,
q: query,
});
};
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
<div className="w-full pl-8">
<p className="font-semibold">{getTaskTypeMap(t)[task.task_type]}</p>
<p>{task.title}</p>
</div>
<button
type="button"
data-testid="task-launch-button"
className={cn(
"underline underline-offset-2 disabled:opacity-80",
isPending && "no-underline font-bold",
)}
disabled={isCreatingConversation}
onClick={handleLaunchConversation}
>
{!isPending && t("HOME$LAUNCH")}
{isPending && t("HOME$LOADING")}
</button>
</li>
);
}
@@ -0,0 +1,22 @@
import { TaskCard } from "./task-card";
import { TaskItemTitle } from "./task-item-title";
import { SuggestedTask } from "./task.types";
interface TaskGroupProps {
title: string;
tasks: SuggestedTask[];
}
export function TaskGroup({ title, tasks }: TaskGroupProps) {
return (
<div className="text-content-2">
<TaskItemTitle>{title}</TaskItemTitle>
<ul className="text-sm">
{tasks.map((task) => (
<TaskCard key={task.issue_number} task={task} />
))}
</ul>
</div>
);
}
@@ -0,0 +1,17 @@
interface TaskIssueNumberProps {
issueNumber: number;
href: string;
}
export function TaskIssueNumber({ href, issueNumber }: TaskIssueNumberProps) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
data-testid="task-id"
>
#<span className="underline underline-offset-2">{issueNumber}</span>
</a>
);
}
@@ -0,0 +1,7 @@
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
return (
<div className="py-3 border-b-1 border-[#717888]">
<h3 className="text-[16px] leading-6 font-[500]">{title}</h3>
</div>
);
}
@@ -0,0 +1,50 @@
import { cn } from "#/utils/utils";
const VALID_WIDTHS = ["w-1/4", "w-1/2", "w-3/4"];
const getRandomWidth = () =>
VALID_WIDTHS[Math.floor(Math.random() * VALID_WIDTHS.length)];
const getRandomNumber = (from = 3, to = 5) =>
Math.floor(Math.random() * (to - from + 1)) + from;
function TaskCardSkeleton() {
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<div className="h-5 w-8 skeleton" />
<div className="w-full pl-8">
<div className="h-5 w-24 skeleton mb-2" />
<div className={cn("h-5 skeleton", getRandomWidth())} />
</div>
<div className="h-5 w-16 skeleton" />
</li>
);
}
interface TaskGroupSkeletonProps {
items?: number;
}
function TaskGroupSkeleton({ items = 3 }: TaskGroupSkeletonProps) {
return (
<div data-testid="task-group-skeleton">
<div className="py-3 border-b border-[#717888]">
<div className="h-6 w-40 skeleton" />
</div>
<ul>
{Array.from({ length: items }).map((_, index) => (
<TaskCardSkeleton key={index} />
))}
</ul>
</div>
);
}
export function TaskSuggestionsSkeleton() {
return Array.from({ length: getRandomNumber(2, 3) }).map((_, index) => (
<TaskGroupSkeleton key={index} items={getRandomNumber(3, 5)} />
));
}
@@ -0,0 +1,43 @@
import { TaskGroup } from "./task-group";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { useAuth } from "#/context/auth-context";
import { cn } from "#/utils/utils";
import { ConnectToProviderMessage } from "../connect-to-provider-message";
interface TaskSuggestionsProps {
filterFor?: string | null;
}
export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
const { providersAreSet } = useAuth();
const { data: tasks, isLoading } = useSuggestedTasks();
const suggestedTasks = filterFor
? tasks?.filter((task) => task.title === filterFor)
: tasks;
const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0;
return (
<section
data-testid="task-suggestions"
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
>
<h2 className="heading">Suggested Tasks</h2>
<div className="flex flex-col gap-6">
{!providersAreSet && <ConnectToProviderMessage />}
{isLoading && <TaskSuggestionsSkeleton />}
{!hasSuggestedTasks && !isLoading && <p>No tasks available</p>}
{suggestedTasks?.map((taskGroup, index) => (
<TaskGroup
key={index}
title={taskGroup.title}
tasks={taskGroup.tasks}
/>
))}
</div>
</section>
);
}
@@ -0,0 +1,17 @@
export type SuggestedTaskType =
| "MERGE_CONFLICTS"
| "FAILING_CHECKS"
| "UNRESOLVED_COMMENTS"
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
export interface SuggestedTask {
issue_number: number;
repo: string;
title: string;
task_type: SuggestedTaskType;
}
export interface SuggestedTaskGroup {
title: string;
tasks: SuggestedTask[];
}
@@ -40,10 +40,6 @@ export function PaymentForm() {
data-testid="billing-settings"
className="flex flex-col gap-6 px-11 py-9"
>
<h2 className="text-[28px] leading-8 tracking-[-0.02em] font-bold">
{t(I18nKey.PAYMENT$MANAGE_CREDITS)}
</h2>
<div
className={cn(
"flex items-center justify-between w-[680px] bg-[#7F7445] rounded px-3 py-2",
@@ -52,7 +48,7 @@ export function PaymentForm() {
>
<div className="flex items-center gap-2">
<MoneyIcon width={22} height={14} />
<span>Balance</span>
<span>{t(I18nKey.PAYMENT$MANAGE_CREDITS)}</span>
</div>
{!isLoading && (
<span data-testid="user-balance">${Number(balance).toFixed(2)}</span>
@@ -0,0 +1,33 @@
import React, { ReactNode } from "react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
interface ApiKeyModalBaseProps {
isOpen: boolean;
title: string;
width?: string;
children: ReactNode;
footer: ReactNode;
}
export function ApiKeyModalBase({
isOpen,
title,
width = "500px",
children,
footer,
}: ApiKeyModalBaseProps) {
if (!isOpen) return null;
return (
<ModalBackdrop>
<div
className="bg-base-secondary p-6 rounded-xl flex flex-col gap-4 border border-tertiary"
style={{ width }}
>
<h3 className="text-xl font-bold">{title}</h3>
{children}
<div className="w-full flex gap-2 mt-2">{footer}</div>
</div>
</ModalBackdrop>
);
}
@@ -0,0 +1,146 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ApiKey, CreateApiKeyResponse } from "#/api/api-keys";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { DeleteApiKeyModal } from "./delete-api-key-modal";
import { NewApiKeyModal } from "./new-api-key-modal";
import { useApiKeys } from "#/hooks/query/use-api-keys";
export function ApiKeysManager() {
const { t } = useTranslation();
const { data: apiKeys = [], isLoading, error } = useApiKeys();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [keyToDelete, setKeyToDelete] = useState<ApiKey | null>(null);
const [newlyCreatedKey, setNewlyCreatedKey] =
useState<CreateApiKeyResponse | null>(null);
const [showNewKeyModal, setShowNewKeyModal] = useState(false);
// Display error toast if the query fails
if (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
const handleKeyCreated = (newKey: CreateApiKeyResponse) => {
setNewlyCreatedKey(newKey);
setCreateModalOpen(false);
setShowNewKeyModal(true);
};
const handleCloseCreateModal = () => {
setCreateModalOpen(false);
};
const handleCloseDeleteModal = () => {
setDeleteModalOpen(false);
setKeyToDelete(null);
};
const handleCloseNewKeyModal = () => {
setShowNewKeyModal(false);
setNewlyCreatedKey(null);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return "Never";
return new Date(dateString).toLocaleString();
};
return (
<>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<BrandButton
type="button"
variant="primary"
onClick={() => setCreateModalOpen(true)}
>
{t(I18nKey.SETTINGS$CREATE_API_KEY)}
</BrandButton>
</div>
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$API_KEYS_DESCRIPTION)}
</p>
{isLoading && (
<div className="flex justify-center p-4">
<LoadingSpinner size="large" />
</div>
)}
{!isLoading && Array.isArray(apiKeys) && apiKeys.length > 0 && (
<div className="border border-tertiary rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-base-tertiary">
<tr>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$NAME)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$CREATED_AT)}
</th>
<th className="text-left p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$LAST_USED)}
</th>
<th className="text-right p-3 text-sm font-medium">
{t(I18nKey.SETTINGS$ACTIONS)}
</th>
</tr>
</thead>
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-t border-tertiary">
<td className="p-3 text-sm">{key.name}</td>
<td className="p-3 text-sm">
{formatDate(key.created_at)}
</td>
<td className="p-3 text-sm">
{formatDate(key.last_used_at)}
</td>
<td className="p-3 text-right">
<button
type="button"
className="underline"
onClick={() => {
setKeyToDelete(key);
setDeleteModalOpen(true);
}}
>
{t(I18nKey.BUTTON$DELETE)}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Create API Key Modal */}
<CreateApiKeyModal
isOpen={createModalOpen}
onClose={handleCloseCreateModal}
onKeyCreated={handleKeyCreated}
/>
{/* Delete API Key Modal */}
<DeleteApiKeyModal
isOpen={deleteModalOpen}
keyToDelete={keyToDelete}
onClose={handleCloseDeleteModal}
/>
{/* Show New API Key Modal */}
<NewApiKeyModal
isOpen={showNewKeyModal}
newlyCreatedKey={newlyCreatedKey}
onClose={handleCloseNewKeyModal}
/>
</>
);
}
@@ -2,7 +2,7 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
variant: "primary" | "secondary";
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
className?: string;
@@ -29,9 +29,10 @@ export function BrandButton({
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
"w-fit p-2 text-sm rounded disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",
startContent && "flex items-center justify-center gap-2",
className,
)}
@@ -0,0 +1,101 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { CreateApiKeyResponse } from "#/api/api-keys";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
import { useCreateApiKey } from "#/hooks/mutation/use-create-api-key";
interface CreateApiKeyModalProps {
isOpen: boolean;
onClose: () => void;
onKeyCreated: (newKey: CreateApiKeyResponse) => void;
}
export function CreateApiKeyModal({
isOpen,
onClose,
onKeyCreated,
}: CreateApiKeyModalProps) {
const { t } = useTranslation();
const [newKeyName, setNewKeyName] = useState("");
const createApiKeyMutation = useCreateApiKey();
const handleCreateKey = async () => {
if (!newKeyName.trim()) {
displayErrorToast(t(I18nKey.ERROR$REQUIRED_FIELD));
return;
}
try {
const newKey = await createApiKeyMutation.mutateAsync(newKeyName);
onKeyCreated(newKey);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_CREATED));
setNewKeyName("");
} catch (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
};
const handleCancel = () => {
setNewKeyName("");
onClose();
};
const modalFooter = (
<>
<BrandButton
type="button"
variant="primary"
className="grow"
onClick={handleCreateKey}
isDisabled={createApiKeyMutation.isPending || !newKeyName.trim()}
>
{createApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.BUTTON$CREATE)
)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={handleCancel}
isDisabled={createApiKeyMutation.isPending}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen}
title={t(I18nKey.SETTINGS$CREATE_API_KEY)}
footer={modalFooter}
>
<div data-testid="create-api-key-modal">
<p className="text-sm text-gray-300">
{t(I18nKey.SETTINGS$CREATE_API_KEY_DESCRIPTION)}
</p>
<SettingsInput
testId="api-key-name-input"
label={t(I18nKey.SETTINGS$NAME)}
placeholder={t(I18nKey.SETTINGS$API_KEY_NAME_PLACEHOLDER)}
value={newKeyName}
onChange={(value) => setNewKeyName(value)}
className="w-full mt-4"
type="text"
/>
</div>
</ApiKeyModalBase>
);
}
@@ -0,0 +1,84 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ApiKey } from "#/api/api-keys";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
import { useDeleteApiKey } from "#/hooks/mutation/use-delete-api-key";
interface DeleteApiKeyModalProps {
isOpen: boolean;
keyToDelete: ApiKey | null;
onClose: () => void;
}
export function DeleteApiKeyModal({
isOpen,
keyToDelete,
onClose,
}: DeleteApiKeyModalProps) {
const { t } = useTranslation();
const deleteApiKeyMutation = useDeleteApiKey();
const handleDeleteKey = async () => {
if (!keyToDelete) return;
try {
await deleteApiKeyMutation.mutateAsync(keyToDelete.id);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_DELETED));
onClose();
} catch (error) {
displayErrorToast(t(I18nKey.ERROR$GENERIC));
}
};
if (!keyToDelete) return null;
const modalFooter = (
<>
<BrandButton
type="button"
variant="danger"
className="grow"
onClick={handleDeleteKey}
isDisabled={deleteApiKeyMutation.isPending}
>
{deleteApiKeyMutation.isPending ? (
<LoadingSpinner size="small" />
) : (
t(I18nKey.BUTTON$DELETE)
)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
className="grow"
onClick={onClose}
isDisabled={deleteApiKeyMutation.isPending}
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen && !!keyToDelete}
title={t(I18nKey.SETTINGS$DELETE_API_KEY)}
footer={modalFooter}
>
<div data-testid="delete-api-key-modal">
<p className="text-sm">
{t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
name: keyToDelete.name,
})}
</p>
</div>
</ApiKeyModalBase>
);
}
@@ -0,0 +1,61 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { CreateApiKeyResponse } from "#/api/api-keys";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
import { ApiKeyModalBase } from "./api-key-modal-base";
interface NewApiKeyModalProps {
isOpen: boolean;
newlyCreatedKey: CreateApiKeyResponse | null;
onClose: () => void;
}
export function NewApiKeyModal({
isOpen,
newlyCreatedKey,
onClose,
}: NewApiKeyModalProps) {
const { t } = useTranslation();
const handleCopyToClipboard = () => {
if (newlyCreatedKey) {
navigator.clipboard.writeText(newlyCreatedKey.key);
displaySuccessToast(t(I18nKey.SETTINGS$API_KEY_COPIED));
}
};
if (!newlyCreatedKey) return null;
const modalFooter = (
<>
<BrandButton
type="button"
variant="primary"
onClick={handleCopyToClipboard}
>
{t(I18nKey.BUTTON$COPY_TO_CLIPBOARD)}
</BrandButton>
<BrandButton type="button" variant="secondary" onClick={onClose}>
{t(I18nKey.BUTTON$CLOSE)}
</BrandButton>
</>
);
return (
<ApiKeyModalBase
isOpen={isOpen && !!newlyCreatedKey}
title={t(I18nKey.SETTINGS$API_KEY_CREATED)}
width="600px"
footer={modalFooter}
>
<div data-testid="new-api-key-modal">
<p className="text-sm">{t(I18nKey.SETTINGS$API_KEY_WARNING)}</p>
<div className="bg-base-tertiary p-4 rounded-md font-mono text-sm break-all mt-4">
{newlyCreatedKey.key}
</div>
</div>
</ApiKeyModalBase>
);
}
@@ -1,42 +1,56 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { ReactNode } from "react";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
interface SettingsDropdownInputProps {
testId: string;
label: ReactNode;
name: string;
items: { key: React.Key; label: string }[];
label?: ReactNode;
wrapperClassName?: string;
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
defaultSelectedKey?: string;
isClearable?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
}
export function SettingsDropdownInput({
testId,
label,
wrapperClassName,
name,
items,
placeholder,
showOptionalTag,
isDisabled,
defaultSelectedKey,
isClearable,
onSelectionChange,
onInputChange,
}: SettingsDropdownInputProps) {
return (
<label className="flex flex-col gap-2.5 w-[680px]">
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (
<div className="flex items-center gap-1">
<span className="text-sm">{label}</span>
{showOptionalTag && <OptionalTag />}
</div>
)}
<Autocomplete
aria-label={typeof label === "string" ? label : name}
data-testid={testId}
name={name}
defaultItems={items}
defaultSelectedKey={defaultSelectedKey}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isClearable={isClearable}
isDisabled={isDisabled}
placeholder={placeholder}
className="w-full"
classNames={{
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
@@ -7,6 +7,7 @@ interface SettingsInputProps {
label: string;
type: React.HTMLInputTypeAttribute;
defaultValue?: string;
value?: string;
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
@@ -24,6 +25,7 @@ export function SettingsInput({
label,
type,
defaultValue,
value,
placeholder,
showOptionalTag,
isDisabled,
@@ -43,11 +45,12 @@ export function SettingsInput({
</div>
<input
data-testid={testId}
onChange={(e) => onChange?.(e.target.value)}
onChange={(e) => onChange && onChange(e.target.value)}
name={name}
disabled={isDisabled}
type={type}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}
@@ -4,8 +4,6 @@ import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { TOSCheckbox } from "./tos-checkbox";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { BrandButton } from "../settings/brand-button";
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
@@ -19,7 +17,6 @@ interface AuthModalProps {
export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
const { t } = useTranslation();
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
@@ -28,14 +25,14 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
const handleGitHubAuth = () => {
if (githubAuthUrl) {
handleCaptureConsent(true);
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = githubAuthUrl;
}
};
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
handleCaptureConsent(true);
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = gitlabAuthUrl;
}
};
@@ -50,11 +47,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
</h1>
</div>
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
<div className="flex flex-col gap-3 w-full">
<BrandButton
isDisabled={!isTosAccepted}
type="button"
variant="primary"
onClick={handleGitHubAuth}
@@ -65,7 +59,6 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
</BrandButton>
<BrandButton
isDisabled={!isTosAccepted}
type="button"
variant="primary"
onClick={handleGitLabAuth}
+5 -1
View File
@@ -10,6 +10,7 @@ interface AuthContextType {
interface AuthContextProps extends React.PropsWithChildren {
initialProviderTokens?: Provider[];
initialProvidersAreSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
@@ -17,12 +18,15 @@ const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({
children,
initialProviderTokens = [],
initialProvidersAreSet = false,
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(false);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(
initialProvidersAreSet,
);
const value = React.useMemo(
() => ({
@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient, { CreateApiKeyResponse } from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
export function useCreateApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (name: string): Promise<CreateApiKeyResponse> =>
ApiKeysClient.createApiKey(name),
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
},
});
}
@@ -5,6 +5,7 @@ import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
export const useCreateConversation = () => {
const navigate = useNavigate();
@@ -16,11 +17,15 @@ export const useCreateConversation = () => {
);
return useMutation({
mutationFn: async (variables: { q?: string }) => {
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
q?: string;
selectedRepository?: GitRepository | null;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
selectedRepository || undefined,
variables.selectedRepository || undefined,
variables.q,
files,
replayJson || undefined,
@@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { API_KEYS_QUERY_KEY } from "#/hooks/query/use-api-keys";
export function useDeleteApiKey() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string): Promise<void> => {
await ApiKeysClient.deleteApiKey(id);
},
onSuccess: () => {
// Invalidate the API keys query to trigger a refetch
queryClient.invalidateQueries({ queryKey: [API_KEYS_QUERY_KEY] });
},
});
}
@@ -29,5 +29,10 @@ export const useLogout = () => {
navigate("/");
window.location.reload();
},
onSuccess: () => {
// Home screen suggested tasks
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["tasks"] });
},
});
};
+22
View File
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const API_KEYS_QUERY_KEY = "api-keys";
export function useApiKeys() {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
return useQuery({
queryKey: [API_KEYS_QUERY_KEY],
enabled: providersAreSet && config?.APP_MODE === "saas",
queryFn: async () => {
const keys = await ApiKeysClient.getApiKeys();
return Array.isArray(keys) ? keys : [];
},
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
}
+13 -1
View File
@@ -2,6 +2,16 @@ import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
// Instead of directly using useLocation, we'll check the current path manually
// This avoids the Router context requirement
const isOnTosPage = () => {
// Only run this check in browser environment
if (typeof window !== "undefined") {
return window.location.pathname === "/accept-tos";
}
return false;
};
export const useBalance = () => {
const { data: config } = useConfig();
@@ -9,6 +19,8 @@ export const useBalance = () => {
queryKey: ["user", "balance"],
queryFn: OpenHands.getBalance,
enabled:
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
!isOnTosPage() &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS.ENABLE_BILLING,
});
};
+12 -1
View File
@@ -1,10 +1,21 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
// Instead of directly using useLocation, we'll check the current path manually
// This avoids the Router context requirement
const isOnTosPage = () => {
// Only run this check in browser environment
if (typeof window !== "undefined") {
return window.location.pathname === "/accept-tos";
}
return false;
};
export const useConfig = () =>
useQuery({
queryKey: ["config"],
queryFn: OpenHands.getConfig,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
gcTime: 1000 * 60 * 15, // 15 minutes,
enabled: !isOnTosPage(),
});
+11 -1
View File
@@ -4,6 +4,16 @@ import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
// Instead of directly using useLocation, we'll check the current path manually
// This avoids the Router context requirement
const isOnTosPage = () => {
// Only run this check in browser environment
if (typeof window !== "undefined") {
return window.location.pathname === "/accept-tos";
}
return false;
};
export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
@@ -13,7 +23,7 @@ export const useIsAuthed = () => {
return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
enabled: !!appMode && !isOnTosPage(),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: false,
+11
View File
@@ -5,6 +5,16 @@ import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
// Instead of directly using useLocation, we'll check the current path manually
// This avoids the Router context requirement
const isOnTosPage = () => {
// Only run this check in browser environment
if (typeof window !== "undefined") {
return window.location.pathname === "/accept-tos";
}
return false;
};
const getSettingsQueryFn = async () => {
const apiSettings = await OpenHands.getSettings();
@@ -39,6 +49,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage(),
meta: {
disableToast: true,
},
@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
import { useAuth } from "#/context/auth-context";
export const useSuggestedTasks = () => {
const { providersAreSet } = useAuth();
return useQuery({
queryKey: ["tasks"],
queryFn: SuggestionsService.getSuggestedTasks,
select: groupSuggestedTasks,
enabled: providersAreSet,
});
};
@@ -0,0 +1,14 @@
import { useIsMutating } from "@tanstack/react-query";
import { useNavigation } from "react-router";
export const useIsCreatingConversation = () => {
const navigation = useNavigation();
const numberOfPendingMutations = useIsMutating({
mutationKey: ["create-conversation"],
});
const isNavigating = Boolean(navigation.location);
const hasPendingMutations = numberOfPendingMutations > 0;
return hasPendingMutations || isNavigating;
};
+38
View File
@@ -1,5 +1,18 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
HOME$CONNECT_PROVIDER_MESSAGE = "HOME$CONNECT_PROVIDER_MESSAGE",
HOME$LETS_START_BUILDING = "HOME$LETS_START_BUILDING",
HOME$OPENHANDS_DESCRIPTION = "HOME$OPENHANDS_DESCRIPTION",
HOME$NOT_SURE_HOW_TO_START = "HOME$NOT_SURE_HOW_TO_START",
HOME$CONNECT_TO_REPOSITORY = "HOME$CONNECT_TO_REPOSITORY",
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
HOME$RESOLVE_UNRESOLVED_COMMENTS = "HOME$RESOLVE_UNRESOLVED_COMMENTS",
HOME$LAUNCH = "HOME$LAUNCH",
CHAT$RESOLVER_INSTRUCTIONS = "CHAT$RESOLVER_INSTRUCTIONS",
SETTINGS$ADVANCED = "SETTINGS$ADVANCED",
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
@@ -256,6 +269,27 @@ export enum I18nKey {
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
SETTINGS$SAVED = "SETTINGS$SAVED",
SETTINGS$RESET = "SETTINGS$RESET",
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
SETTINGS$CREATE_API_KEY = "SETTINGS$CREATE_API_KEY",
SETTINGS$CREATE_API_KEY_DESCRIPTION = "SETTINGS$CREATE_API_KEY_DESCRIPTION",
SETTINGS$DELETE_API_KEY = "SETTINGS$DELETE_API_KEY",
SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION",
SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS",
SETTINGS$NAME = "SETTINGS$NAME",
SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX",
SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT",
SETTINGS$LAST_USED = "SETTINGS$LAST_USED",
SETTINGS$ACTIONS = "SETTINGS$ACTIONS",
SETTINGS$API_KEY_CREATED = "SETTINGS$API_KEY_CREATED",
SETTINGS$API_KEY_DELETED = "SETTINGS$API_KEY_DELETED",
SETTINGS$API_KEY_WARNING = "SETTINGS$API_KEY_WARNING",
SETTINGS$API_KEY_COPIED = "SETTINGS$API_KEY_COPIED",
SETTINGS$API_KEY_NAME_PLACEHOLDER = "SETTINGS$API_KEY_NAME_PLACEHOLDER",
BUTTON$CREATE = "BUTTON$CREATE",
BUTTON$DELETE = "BUTTON$DELETE",
BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD",
ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD",
PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE",
FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL",
FEEDBACK$PRIVATE_LABEL = "FEEDBACK$PRIVATE_LABEL",
@@ -424,4 +458,8 @@ export enum I18nKey {
SYSTEM_MESSAGE_MODAL$TOOLS_TAB = "SYSTEM_MESSAGE_MODAL$TOOLS_TAB",
SYSTEM_MESSAGE_MODAL$PARAMETERS = "SYSTEM_MESSAGE_MODAL$PARAMETERS",
SYSTEM_MESSAGE_MODAL$NO_TOOLS = "SYSTEM_MESSAGE_MODAL$NO_TOOLS",
TOS$ACCEPT_TERMS_OF_SERVICE = "TOS$ACCEPT_TERMS_OF_SERVICE",
TOS$ACCEPT_TERMS_DESCRIPTION = "TOS$ACCEPT_TERMS_DESCRIPTION",
TOS$CONTINUE = "TOS$CONTINUE",
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
}
+307
View File
@@ -1,4 +1,199 @@
{
"HOME$CONNECT_PROVIDER_MESSAGE": {
"en": "To get started with suggested tasks, please connect your GitHub or GitLab account.",
"ja": "提案されたタスクを始めるには、GitHubまたはGitLabアカウントを接続してください。",
"zh-CN": "要开始使用建议的任务,请连接您的GitHub或GitLab账户。",
"zh-TW": "要開始使用建議的任務,請連接您的GitHub或GitLab帳戶。",
"ko-KR": "제안된 작업을 시작하려면 GitHub 또는 GitLab 계정을 연결하세요.",
"no": "For å komme i gang med foreslåtte oppgaver, vennligst koble til GitHub eller GitLab-kontoen din.",
"it": "Per iniziare con le attività suggerite, collega il tuo account GitHub o GitLab.",
"pt": "Para começar com tarefas sugeridas, conecte sua conta GitHub ou GitLab.",
"es": "Para comenzar con las tareas sugeridas, conecte su cuenta de GitHub o GitLab.",
"ar": "للبدء بالمهام المقترحة، يرجى ربط حساب GitHub أو GitLab الخاص بك.",
"fr": "Pour commencer avec les tâches suggérées, veuillez connecter votre compte GitHub ou GitLab.",
"tr": "Önerilen görevlerle başlamak için lütfen GitHub veya GitLab hesabınızı bağlayın.",
"de": "Um mit vorgeschlagenen Aufgaben zu beginnen, verbinden Sie bitte Ihr GitHub- oder GitLab-Konto."
},
"HOME$LETS_START_BUILDING": {
"en": "Let's Start Building!",
"ja": "構築を始めましょう!",
"zh-CN": "让我们开始构建!",
"zh-TW": "讓我們開始構建!",
"ko-KR": "구축을 시작합시다!",
"no": "La oss begynne å bygge!",
"it": "Iniziamo a costruire!",
"pt": "Vamos começar a construir!",
"es": "¡Comencemos a construir!",
"ar": "لنبدأ البناء!",
"fr": "Commençons à construire !",
"tr": "Hadi İnşa Etmeye Başlayalım!",
"de": "Lass uns anfangen zu bauen!"
},
"HOME$OPENHANDS_DESCRIPTION": {
"en": "OpenHands makes it easy to build and maintain software using AI-driven development.",
"ja": "OpenHandsはAI駆動の開発を使用してソフトウェアの構築と維持を容易にします。",
"zh-CN": "OpenHands使用AI驱动的开发方式,轻松构建和维护软件。",
"zh-TW": "OpenHands使用AI驅動的開發方式,輕鬆構建和維護軟件。",
"ko-KR": "OpenHands는 AI 기반 개발을 사용하여 소프트웨어를 쉽게 구축하고 유지할 수 있게 합니다.",
"no": "OpenHands gjør det enkelt å bygge og vedlikeholde programvare ved hjelp av AI-drevet utvikling.",
"it": "OpenHands rende facile costruire e mantenere software utilizzando lo sviluppo guidato dall'IA.",
"pt": "OpenHands facilita a construção e manutenção de software usando desenvolvimento orientado por IA.",
"es": "OpenHands facilita la construcción y el mantenimiento de software utilizando desarrollo impulsado por IA.",
"ar": "يجعل OpenHands من السهل بناء وصيانة البرمجيات باستخدام التطوير المدعوم بالذكاء الاصطناعي.",
"fr": "OpenHands facilite la création et la maintenance de logiciels grâce au développement piloté par l'IA.",
"tr": "OpenHands, yapay zeka destekli geliştirme kullanarak yazılım oluşturmayı ve sürdürmeyi kolaylaştırır.",
"de": "OpenHands macht es einfach, Software mit KI-gesteuerter Entwicklung zu erstellen und zu warten."
},
"HOME$NOT_SURE_HOW_TO_START": {
"en": "Not sure how to start?",
"ja": "始め方がわからない?",
"zh-CN": "不确定如何开始?",
"zh-TW": "不確定如何開始?",
"ko-KR": "시작 방법을 모르시나요?",
"no": "Usikker på hvordan du skal starte?",
"it": "Non sei sicuro di come iniziare?",
"pt": "Não tem certeza de como começar?",
"es": "¿No está seguro de cómo empezar?",
"ar": "غير متأكد من كيفية البدء؟",
"fr": "Vous ne savez pas par où commencer ?",
"tr": "Nasıl başlayacağınızdan emin değil misiniz?",
"de": "Nicht sicher, wie man anfängt?"
},
"HOME$CONNECT_TO_REPOSITORY": {
"en": "Connect to a Repository",
"ja": "リポジトリに接続",
"zh-CN": "连接到仓库",
"zh-TW": "連接到存儲庫",
"ko-KR": "저장소에 연결",
"no": "Koble til et repository",
"it": "Connetti a un repository",
"pt": "Conectar a um repositório",
"es": "Conectar a un repositorio",
"ar": "الاتصال بمستودع",
"fr": "Se connecter à un dépôt",
"tr": "Bir Depoya Bağlan",
"de": "Mit einem Repository verbinden"
},
"HOME$LOADING": {
"en": "Loading...",
"ja": "読み込み中...",
"zh-CN": "加载中...",
"zh-TW": "載入中...",
"ko-KR": "로딩 중...",
"no": "Laster...",
"it": "Caricamento in corso...",
"pt": "Carregando...",
"es": "Cargando...",
"ar": "جار التحميل...",
"fr": "Chargement...",
"tr": "Yükleniyor...",
"de": "Wird geladen..."
},
"HOME$LOADING_REPOSITORIES": {
"en": "Loading repositories...",
"ja": "リポジトリを読み込み中...",
"zh-CN": "加载仓库中...",
"zh-TW": "載入儲存庫中...",
"ko-KR": "저장소 로딩 중...",
"no": "Laster repositories...",
"it": "Caricamento repository in corso...",
"pt": "Carregando repositórios...",
"es": "Cargando repositorios...",
"ar": "جار تحميل المستودعات...",
"fr": "Chargement des dépôts...",
"tr": "Depolar yükleniyor...",
"de": "Repositories werden geladen..."
},
"HOME$FAILED_TO_LOAD_REPOSITORIES": {
"en": "Failed to load repositories",
"ja": "リポジトリの読み込みに失敗しました",
"zh-CN": "加载仓库失败",
"zh-TW": "載入儲存庫失敗",
"ko-KR": "저장소 로딩 실패",
"no": "Kunne ikke laste repositories",
"it": "Impossibile caricare i repository",
"pt": "Falha ao carregar repositórios",
"es": "Error al cargar repositorios",
"ar": "فشل في تحميل المستودعات",
"fr": "Échec du chargement des dépôts",
"tr": "Depolar yüklenemedi",
"de": "Fehler beim Laden der Repositories"
},
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",
"zh-CN": "打开问题",
"zh-TW": "開放議題",
"ko-KR": "열린 이슈",
"no": "Åpent problem",
"it": "Problema aperto",
"pt": "Problema aberto",
"es": "Problema abierto",
"ar": "مشكلة مفتوحة",
"fr": "Problème ouvert",
"tr": "Açık sorun",
"de": "Offenes Problem"
},
"HOME$FIX_FAILING_CHECKS": {
"en": "Fix failing checks",
"ja": "失敗したチェックを修正",
"zh-CN": "修复失败的检查",
"zh-TW": "修復失敗的檢查",
"ko-KR": "실패한 검사 수정",
"no": "Fikse mislykkede kontroller",
"it": "Correggere i controlli falliti",
"pt": "Corrigir verificações com falha",
"es": "Corregir comprobaciones fallidas",
"ar": "إصلاح الفحوصات الفاشلة",
"fr": "Corriger les vérifications échouées",
"tr": "Başarısız kontrolleri düzelt",
"de": "Fehlgeschlagene Prüfungen beheben"
},
"HOME$RESOLVE_MERGE_CONFLICTS": {
"en": "Resolve merge conflicts",
"ja": "マージ競合を解決",
"zh-CN": "解决合并冲突",
"zh-TW": "解決合併衝突",
"ko-KR": "병합 충돌 해결",
"no": "Løse fletteproblemer",
"it": "Risolvere i conflitti di unione",
"pt": "Resolver conflitos de mesclagem",
"es": "Resolver conflictos de fusión",
"ar": "حل تعارضات الدمج",
"fr": "Résoudre les conflits de fusion",
"tr": "Birleştirme çakışmalarını çöz",
"de": "Merge-Konflikte lösen"
},
"HOME$RESOLVE_UNRESOLVED_COMMENTS": {
"en": "Resolve unresolved comments",
"ja": "未解決のコメントを解決",
"zh-CN": "解决未解决的评论",
"zh-TW": "解決未解決的評論",
"ko-KR": "미해결 댓글 해결",
"no": "Løse uløste kommentarer",
"it": "Risolvere i commenti non risolti",
"pt": "Resolver comentários não resolvidos",
"es": "Resolver comentarios no resueltos",
"ar": "حل التعليقات غير المحلولة",
"fr": "Résoudre les commentaires non résolus",
"tr": "Çözülmemiş yorumları çöz",
"de": "Ungelöste Kommentare beheben"
},
"HOME$LAUNCH": {
"en": "Launch",
"ja": "起動",
"zh-CN": "启动",
"zh-TW": "啟動",
"ko-KR": "실행",
"no": "Start",
"it": "Avvia",
"pt": "Iniciar",
"es": "Iniciar",
"ar": "تشغيل",
"fr": "Lancer",
"tr": "Başlat",
"de": "Starten"
},
"CHAT$RESOLVER_INSTRUCTIONS": {
"en": "Resolver Instructions",
"ja": "リゾルバの指示",
@@ -3818,6 +4013,69 @@
"tr": "Ayarlar sıfırlandı",
"de": "Einstellungen zurückgesetzt"
},
"SETTINGS$API_KEYS": {
"en": "API Keys"
},
"SETTINGS$API_KEYS_DESCRIPTION": {
"en": "API keys allow you to authenticate with the OpenHands API programmatically. Keep your API keys secure; anyone with your API key can access your account."
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key"
},
"SETTINGS$CREATE_API_KEY_DESCRIPTION": {
"en": "Give your API key a descriptive name to help you identify it later."
},
"SETTINGS$DELETE_API_KEY": {
"en": "Delete API Key"
},
"SETTINGS$DELETE_API_KEY_CONFIRMATION": {
"en": "Are you sure you want to delete the API key \"{{name}}\"? This action cannot be undone."
},
"SETTINGS$NO_API_KEYS": {
"en": "You don't have any API keys yet. Create one to get started."
},
"SETTINGS$NAME": {
"en": "Name"
},
"SETTINGS$KEY_PREFIX": {
"en": "Key Prefix"
},
"SETTINGS$CREATED_AT": {
"en": "Created"
},
"SETTINGS$LAST_USED": {
"en": "Last Used"
},
"SETTINGS$ACTIONS": {
"en": "Actions"
},
"SETTINGS$API_KEY_CREATED": {
"en": "API Key Created"
},
"SETTINGS$API_KEY_DELETED": {
"en": "API key deleted successfully"
},
"SETTINGS$API_KEY_WARNING": {
"en": "This is the only time your API key will be displayed. Please copy it now and store it securely."
},
"SETTINGS$API_KEY_COPIED": {
"en": "API key copied to clipboard"
},
"SETTINGS$API_KEY_NAME_PLACEHOLDER": {
"en": "My API Key"
},
"BUTTON$CREATE": {
"en": "Create"
},
"BUTTON$DELETE": {
"en": "Delete"
},
"BUTTON$COPY_TO_CLIPBOARD": {
"en": "Copy to Clipboard"
},
"ERROR$REQUIRED_FIELD": {
"en": "This field is required"
},
"PLANNER$EMPTY_MESSAGE": {
"en": "No plan created.",
"zh-CN": "计划未创建",
@@ -6328,4 +6586,53 @@
"es": "No hay herramientas disponibles para este agente",
"tr": "Bu ajan için kullanılabilir araç yok"
}
,
"TOS$ACCEPT_TERMS_OF_SERVICE": {
"en": "Accept Terms of Service",
"ja": "利用規約に同意する",
"zh-CN": "接受服务条款",
"zh-TW": "接受服務條款",
"ko-KR": "서비스 약관 동의",
"fr": "Accepter les conditions d'utilisation",
"es": "Aceptar términos de servicio",
"de": "Nutzungsbedingungen akzeptieren",
"it": "Accetta i termini di servizio",
"pt": "Aceitar termos de serviço"
},
"TOS$ACCEPT_TERMS_DESCRIPTION": {
"en": "Please review and accept our terms of service before continuing",
"ja": "続行する前に利用規約を確認して同意してください",
"zh-CN": "请在继续之前查看并接受我们的服务条款",
"zh-TW": "請在繼續之前查看並接受我們的服務條款",
"ko-KR": "계속하기 전에 서비스 약관을 검토하고 동의해 주세요",
"fr": "Veuillez examiner et accepter nos conditions d'utilisation avant de continuer",
"es": "Por favor, revise y acepte nuestros términos de servicio antes de continuar",
"de": "Bitte überprüfen und akzeptieren Sie unsere Nutzungsbedingungen, bevor Sie fortfahren",
"it": "Si prega di rivedere e accettare i nostri termini di servizio prima di continuare",
"pt": "Por favor, revise e aceite nossos termos de serviço antes de continuar"
},
"TOS$CONTINUE": {
"en": "Continue",
"ja": "続行",
"zh-CN": "继续",
"zh-TW": "繼續",
"ko-KR": "계속",
"fr": "Continuer",
"es": "Continuar",
"de": "Fortfahren",
"it": "Continua",
"pt": "Continuar"
},
"TOS$ERROR_ACCEPTING": {
"en": "Error accepting Terms of Service",
"ja": "利用規約の承諾中にエラーが発生しました",
"zh-CN": "接受服务条款时出错",
"zh-TW": "接受服務條款時出錯",
"ko-KR": "서비스 약관 수락 중 오류 발생",
"fr": "Erreur lors de l'acceptation des conditions d'utilisation",
"es": "Error al aceptar los Términos de Servicio",
"de": "Fehler beim Akzeptieren der Nutzungsbedingungen",
"it": "Errore nell'accettazione dei Termini di Servizio",
"pt": "Erro ao aceitar os Termos de Serviço"
}
}
+18
View File
@@ -0,0 +1,18 @@
<svg width="102" height="69" viewBox="0 0 102 69" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_40000615_2266)">
<g clip-path="url(#clip1_40000615_2266)">
<path d="M97.9659 20.9744C94.7109 18.9968 92.5363 22.0276 92.8031 26.1428L92.7759 26.1739C92.785 21.8765 92.1973 17.1303 90.2443 13.2906C89.5526 11.9307 88.1511 9.69537 85.3798 10.7486C84.1637 11.2108 83.0606 12.6018 83.6302 16.1926C83.6302 16.1926 84.2631 19.9611 84.1456 24.6985V24.7651C83.3544 11.6996 80.3707 7.71333 76.103 7.97108C74.7376 8.21106 72.8705 8.79323 73.4989 12.8107C73.4989 12.8107 74.1816 17.0014 74.4031 20.3389L74.4167 20.5077H74.4031C72.3958 13.3039 69.6924 13.2062 67.7348 13.4861C65.9581 13.7395 64.0187 15.5615 64.9997 19.1523C68.0784 30.418 67.4771 43.9857 67.2466 45.9277C66.6182 44.5989 66.4238 43.5457 65.5512 42.088C62.043 36.2352 60.3748 35.8042 58.3269 35.7153C56.2925 35.6264 54.0953 36.8663 54.24 39.2261C54.3892 41.5859 55.6053 41.9769 57.3323 45.2655C58.6795 47.8253 59.0638 51.1806 61.7763 57.2778C64.0232 62.3262 69.8958 67.8635 80.5922 67.2058C89.2587 66.9214 102.202 63.9261 99.9506 44.2567C99.39 40.8393 99.8104 37.9773 100.104 35.0442C100.561 30.4935 101.23 22.952 97.9704 20.9699L97.9659 20.9744Z" fill="#E3D495"/>
<path d="M43.8511 35.8532C41.8032 35.9821 40.1485 36.4398 36.7488 42.3548C35.9034 43.8258 35.7316 44.8835 35.1258 46.2212C34.8591 44.2835 34.0046 30.7292 36.8709 19.4102C37.7841 15.8061 35.813 14.0196 34.0318 13.7974C32.0697 13.553 29.3617 13.6996 27.4901 20.9745H27.4675L27.4946 20.7656C27.6528 17.4237 28.2586 13.2241 28.2586 13.2241C28.8056 9.19334 26.934 8.64672 25.5642 8.42896C21.3055 8.2512 18.4031 12.2553 17.8425 25.2052H17.8335C17.6391 20.5168 18.1951 16.7838 18.1951 16.7838C18.697 13.1797 17.5667 11.8109 16.3416 11.3709C13.5522 10.3666 12.1914 12.6286 11.5268 14.0018C9.64616 17.877 9.14434 22.6321 9.23476 26.9295L9.20763 26.8984C9.39299 22.7788 7.1642 19.788 3.94533 21.8233C0.726467 23.8631 1.5357 31.3914 2.07821 35.9332C2.43084 38.8618 2.90101 41.7149 2.40371 45.1413C0.523028 64.8462 13.5205 67.6059 22.1916 67.7348C32.897 68.2014 38.6657 62.553 40.8176 57.4646C43.4126 51.3229 43.7381 47.9588 45.0356 45.3768C46.6993 42.0571 47.9109 41.6438 48.0148 39.284C48.1188 36.9242 45.8991 35.7243 43.8647 35.8487L43.8511 35.8532Z" fill="#E3D495"/>
<path d="M48.1374 35.8835C47.0524 34.8392 45.4204 34.2837 43.7612 34.3815C41.1029 34.5459 39.2177 35.5014 36.3198 40.1676C36.243 34.8303 36.5459 26.8177 38.3271 19.7694C38.9962 17.1208 38.3226 15.392 37.6354 14.4099C36.8352 13.2633 35.592 12.5078 34.2176 12.339C32.9653 12.1834 31.3288 12.1745 29.7329 13.4633C29.7329 13.4455 29.7374 13.4233 29.7374 13.4233C30.2528 9.63699 28.9237 7.4683 25.7907 6.97945L25.6189 6.96167C23.6885 6.87724 22.0339 7.4994 20.7002 8.80595C19.9859 9.50367 19.3665 10.4058 18.8286 11.5213C18.2273 10.6547 17.4542 10.2147 16.8439 9.99251C13.1142 8.64597 11.0527 11.5302 10.1575 13.37C9.11772 15.512 8.47123 17.9118 8.10956 20.3605C8.03271 20.3116 7.96037 20.2627 7.88352 20.2183C7.06072 19.7516 5.34731 19.1828 3.12304 20.5916C-0.647376 22.9825 -0.168163 29.853 0.582303 36.1102C0.622991 36.4435 0.663679 36.7724 0.704367 37.1057C1.02535 39.6699 1.32825 42.0919 0.912327 44.9405L0.903285 45.0116C0.148299 52.9176 1.66279 59.0015 5.40608 63.1033C9.0047 67.0497 14.6422 69.1028 22.1152 69.2139C22.6623 69.2361 23.1957 69.245 23.7156 69.2406C36.4826 69.125 41.0622 60.7036 42.1879 58.0371C43.6437 54.5885 44.3986 52.0154 44.9999 49.9445C45.4656 48.3447 45.8363 47.0825 46.3607 46.0337C46.9575 44.8427 47.4819 44.0695 47.943 43.3851C48.7296 42.2208 49.4078 41.2164 49.4892 39.3499C49.5479 37.9989 49.0777 36.799 48.1238 35.8835H48.1374ZM22.8205 10.8947C23.5393 10.1925 24.3666 9.88141 25.4155 9.90363C26.3242 10.0503 27.147 10.268 26.7717 13.0234C26.7446 13.1967 26.1569 17.3341 25.9987 20.7027C25.9987 20.7249 25.9987 20.7471 25.9987 20.7693C25.1714 24.0579 24.4797 28.7953 24.1089 35.7191C22.504 35.8169 20.9036 35.9858 19.3485 36.208C18.8466 22.1603 20.0085 13.6411 22.816 10.8947H22.8205ZM12.87 14.641C13.9957 12.3301 14.9406 12.4456 15.8177 12.7612C17.0338 13.2011 16.8439 15.5876 16.7038 16.5786C16.6811 16.7386 16.1386 20.4583 16.3285 25.1912C16.1884 28.5109 16.2065 32.3372 16.3737 36.7146C14.8864 37.0034 13.4758 37.3367 12.1874 37.6967C11.5771 35.6791 8.87811 22.8625 12.87 14.6454V14.641ZM45.4565 41.7541C44.9683 42.4741 44.3625 43.3718 43.6798 44.7316C43.0333 46.016 42.6355 47.3936 42.1246 49.1313C41.5414 51.1266 40.8136 53.6109 39.4257 56.9039C38.4401 59.2326 34.299 66.7963 22.2057 66.2631C15.465 66.1653 10.7 64.4854 7.63939 61.1302C4.48382 57.6727 3.2225 52.3487 3.88706 45.3138C4.34819 42.0963 4.00913 39.3721 3.6791 36.7412C3.63842 36.4124 3.59773 36.088 3.55704 35.7591C3.19537 32.7238 2.2279 24.6579 4.74603 23.0625C5.42416 22.6314 5.98023 22.5336 6.39163 22.7647C7.09237 23.1602 7.79762 24.6001 7.69816 26.831C7.69364 26.951 7.7072 27.0666 7.72981 27.1821C7.85639 32.3816 8.80126 36.9057 9.33924 38.6033C8.45767 38.9277 7.68008 39.2655 7.02907 39.6032C6.29669 39.9854 6.02092 40.8742 6.40971 41.5941C6.68097 42.0963 7.20539 42.3807 7.74789 42.3763C7.97846 42.3763 8.21354 42.3185 8.43507 42.203C12.0925 40.2965 20.768 38.3589 28.4128 38.5677C29.2447 38.5811 29.9273 37.95 29.9499 37.1368C29.9725 36.3235 29.3215 35.648 28.4942 35.6258C28.0376 35.6125 27.5765 35.6125 27.1153 35.6125C27.8794 21.6493 29.9816 17.0941 31.6181 15.7476C32.2963 15.1921 32.9427 15.1476 33.8379 15.2587C34.0865 15.2898 34.7104 15.432 35.158 16.072C35.6417 16.7697 35.7321 17.8007 35.4157 19.0539C32.6489 29.9907 33.2728 42.8518 33.5892 45.9715C33.535 46.0782 33.4852 46.1848 33.4265 46.2959C32.78 47.4914 31.5774 48.7357 30.1443 48.6513C29.326 48.6113 28.6072 49.2246 28.5575 50.0334C28.5078 50.8467 29.1362 51.5444 29.9635 51.5933C32.3867 51.7355 34.6697 50.2734 36.0712 47.6825C36.2204 47.407 36.347 47.1448 36.46 46.8914C36.469 46.8737 36.4781 46.8515 36.4871 46.8337C36.7493 46.256 36.9392 45.7271 37.1065 45.2516C37.3687 44.5139 37.5947 43.8739 38.0468 43.0829C41.2566 37.4923 42.5586 37.4123 43.9375 37.3279C44.7468 37.279 45.5515 37.5323 46.0262 37.9945C46.3652 38.3189 46.5144 38.7233 46.4918 39.2299C46.4466 40.2743 46.1527 40.7098 45.443 41.7586L45.4565 41.7541Z" fill="#0D0F11"/>
<path d="M101.42 44.0245C100.954 41.1848 101.212 38.7583 101.483 36.1896C101.519 35.8563 101.556 35.5275 101.587 35.1942C102.22 28.9281 102.573 22.0442 98.7526 19.7244C96.5013 18.3557 94.7969 18.9556 93.9831 19.4356C93.9063 19.48 93.8339 19.5333 93.7571 19.5822C93.3457 17.1424 92.6585 14.756 91.578 12.6317C90.6512 10.8097 88.54 7.96104 84.8329 9.37425C84.2271 9.60534 83.4676 10.0586 82.8798 10.9386C82.3193 9.83198 81.6818 8.94317 80.954 8.25879C79.5977 6.9789 77.9295 6.3834 76.0036 6.50339L75.8318 6.52117C72.7079 7.06779 71.4194 9.2587 72.0071 13.045C72.0071 13.045 72.0071 13.0628 72.0117 13.0761C70.3932 11.814 68.7566 11.854 67.5089 12.0318C66.139 12.2273 64.9094 13.005 64.1318 14.1649C63.4672 15.1604 62.8207 16.898 63.5395 19.5333C65.4564 26.5505 65.9085 34.5587 65.9311 39.896C62.9473 35.2831 61.044 34.3631 58.3857 34.2476C56.7311 34.1765 55.0991 34.7675 54.0366 35.8297C53.1008 36.7629 52.6533 37.9717 52.7392 39.3182C52.8567 41.1803 53.5529 42.1758 54.3622 43.3223C54.8368 43.9978 55.3748 44.7622 55.9942 45.9399C56.5412 46.9798 56.9345 48.233 57.4318 49.824C58.0738 51.8816 58.874 54.4413 60.3975 57.8633C61.573 60.5075 66.3108 68.849 79.0416 68.729C79.557 68.7245 80.0905 68.7067 80.633 68.6712C88.1467 68.4268 93.7435 66.267 97.2698 62.2584C100.932 58.0899 102.333 51.9793 101.434 44.0867L101.424 44.0156L101.42 44.0245ZM85.1041 15.9692C84.9459 14.9649 84.7063 12.5829 85.9179 12.1251C86.7859 11.7918 87.7353 11.6629 88.9017 13.9516C93.0473 22.0976 90.5925 34.9631 90.0183 36.9896C88.7208 36.6518 87.3058 36.3452 85.8139 36.083C85.8953 31.7056 85.841 27.8748 85.6421 24.5596C85.7416 19.8266 85.1312 16.1159 85.1041 15.9692ZM76.2658 9.44091C77.3192 9.39647 78.151 9.69422 78.8789 10.3875C81.7361 13.085 83.0607 21.5776 82.8211 35.6341C81.2614 35.4386 79.661 35.3008 78.0515 35.2297C77.5543 28.3103 76.7721 23.5908 75.8815 20.3155C75.8815 20.2933 75.8815 20.2711 75.8815 20.2488C75.66 16.8802 74.9909 12.7562 74.9638 12.5962C74.5343 9.83643 75.3526 9.60534 76.2613 9.44091H76.2658ZM98.4588 44.46C99.2545 51.4816 98.0926 56.8278 95.0048 60.343C92.0075 63.7516 87.2741 65.5204 80.4928 65.7426C68.4673 66.4892 64.1725 59.0054 63.1462 56.6945C61.6905 53.4237 60.9174 50.9572 60.2981 48.9707C59.7556 47.2375 59.3306 45.8732 58.6615 44.5978C57.9562 43.2512 57.3324 42.3669 56.8306 41.6558C56.1027 40.6204 55.7998 40.1893 55.732 39.1449C55.7003 38.6383 55.845 38.2295 56.175 37.9006C56.6452 37.4295 57.4409 37.1584 58.2546 37.1984C59.6335 37.2607 60.9355 37.314 64.2538 42.8468C64.724 43.629 64.9591 44.2645 65.2349 44.9977C65.4157 45.4733 65.6146 46.0021 65.8904 46.5754C65.8994 46.5932 65.904 46.6109 65.913 46.6243C66.0351 46.8776 66.1662 47.1353 66.3199 47.4109C67.7711 49.9751 70.0812 51.3972 72.4999 51.2105C73.3227 51.1483 73.9421 50.4373 73.8788 49.6284C73.8155 48.8196 73.0967 48.2197 72.2693 48.273C70.8362 48.3797 69.6111 47.1576 68.942 45.9754C68.8787 45.8643 68.829 45.7621 68.7747 45.6555C69.0324 42.5357 69.4167 29.6613 66.4419 18.7779C66.0984 17.5291 66.1707 16.4981 66.6409 15.7915C67.0794 15.1426 67.6987 14.9871 67.9474 14.9515C68.838 14.8227 69.489 14.8582 70.1762 15.4004C71.8399 16.7203 74.028 21.2354 75.0497 35.1808C74.5886 35.1853 74.1274 35.1986 73.6753 35.2208C72.848 35.2564 72.2106 35.9452 72.2467 36.7585C72.2829 37.5717 72.9701 38.185 73.811 38.1628C81.4467 37.8162 90.163 39.5938 93.852 41.4381C94.0735 41.5492 94.3086 41.598 94.5437 41.598C95.0862 41.5936 95.6061 41.3003 95.8683 40.7892C96.2436 40.0649 95.9497 39.176 95.2083 38.8072C94.5528 38.4783 93.7661 38.1584 92.88 37.8473C93.3864 36.1408 94.2498 31.5989 94.277 26.3994C94.2996 26.2839 94.3086 26.1683 94.2996 26.0483C94.1549 23.8218 94.8376 22.3686 95.5293 21.9598C95.9361 21.7198 96.4922 21.8087 97.1794 22.2264C99.7292 23.7774 98.9154 31.8567 98.608 34.9009C98.5763 35.2297 98.5402 35.5541 98.504 35.883C98.2237 38.5228 97.9344 41.247 98.4588 44.46Z" fill="#0D0F11"/>
</g>
</g>
<defs>
<clipPath id="clip0_40000615_2266">
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
</clipPath>
<clipPath id="clip1_40000615_2266">
<rect width="101.919" height="68.945" fill="white" transform="translate(0.0621948 0.29541)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+24 -8
View File
@@ -8,7 +8,8 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import { STRIPE_BILLING_HANDLERS } from "./billing-handlers";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { FILE_SERVICE_HANDLERS } from "./file-service-handlers";
import { GitUser } from "#/types/git";
import { GitRepository, GitUser } from "#/types/git";
import { TASK_SUGGESTIONS_HANDLERS } from "./task-suggestions-handlers";
export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
@@ -105,13 +106,26 @@ const openHandsHandlers = [
export const handlers = [
...STRIPE_BILLING_HANDLERS,
...FILE_SERVICE_HANDLERS,
...TASK_SUGGESTIONS_HANDLERS,
...openHandsHandlers,
http.get("/api/user/repositories", () =>
HttpResponse.json([
{ id: 1, full_name: "octocat/hello-world" },
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/user/repositories", () => {
const data: GitRepository[] = [
{
id: 1,
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
},
];
return HttpResponse.json(data);
}),
http.get("/api/user/info", () => {
const user: GitUser = {
id: 1,
@@ -231,7 +245,9 @@ export const handlers = [
},
),
http.post("/api/conversations", () => {
http.post("/api/conversations", async () => {
await delay();
const conversation: Conversation = {
conversation_id: (Math.random() * 100).toString(),
title: "New Conversation",
@@ -0,0 +1,76 @@
import { http, HttpResponse } from "msw";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
const TASKS_1: SuggestedTask[] = [
{
issue_number: 6968,
title: "Fix merge conflicts",
repo: "octocat/hello-world",
task_type: "MERGE_CONFLICTS",
},
];
const TASKS_2: SuggestedTask[] = [
{
issue_number: 268,
title: "Fix broken CI checks",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 281,
title: "Fix issue",
repo: "octocat/earth",
task_type: "UNRESOLVED_COMMENTS",
},
{
issue_number: 293,
title: "Update documentation",
repo: "octocat/earth",
task_type: "OPEN_ISSUE",
},
{
issue_number: 305,
title: "Refactor user service",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 312,
title: "Fix styling bug",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 327,
title: "Add unit tests",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 331,
title: "Implement dark mode",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 345,
title: "Optimize build process",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
{
issue_number: 352,
title: "Update dependencies",
repo: "octocat/earth",
task_type: "FAILING_CHECKS",
},
];
export const MOCK_TASKS = [...TASKS_1, ...TASKS_2];
export const TASK_SUGGESTIONS_HANDLERS = [
http.get("/api/user/suggested-tasks", async () =>
HttpResponse.json(MOCK_TASKS),
),
];
+2
View File
@@ -8,9 +8,11 @@ import {
export default [
layout("routes/root-layout.tsx", [
index("routes/home.tsx"),
route("accept-tos", "routes/accept-tos.tsx"),
route("settings", "routes/settings.tsx", [
index("routes/account-settings.tsx"),
route("billing", "routes/billing.tsx"),
route("api-keys", "routes/api-keys.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx", [
index("routes/editor.tsx"),
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router";
import { toast } from "react-toastify";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { TOSCheckbox } from "#/components/features/waitlist/tos-checkbox";
import { BrandButton } from "#/components/features/settings/brand-button";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { openHands } from "#/api/open-hands-axios";
export default function AcceptTOS() {
const { t } = useTranslation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [isTosAccepted, setIsTosAccepted] = React.useState(false);
const [isSubmitting, setIsSubmitting] = React.useState(false);
// Get the redirect URL from the query parameters
const redirectUrl = searchParams.get("redirect_url") || "/";
const handleAcceptTOS = async () => {
if (isTosAccepted && !isSubmitting) {
try {
setIsSubmitting(true);
// Set consent for analytics
handleCaptureConsent(true);
// Call the API to record TOS acceptance in the database
const response = await openHands.post("/api/accept_tos", {
redirect_url: redirectUrl,
});
// Get the redirect URL from the response
const finalRedirectUrl = response.data.redirect_url || redirectUrl;
// Check if the redirect URL is an external URL (starts with http or https)
if (
finalRedirectUrl.startsWith("http://") ||
finalRedirectUrl.startsWith("https://")
) {
// For external URLs, redirect using window.location
window.location.href = finalRedirectUrl;
} else {
// For internal routes, use navigate
navigate(finalRedirectUrl);
}
} catch (error) {
console.error(t(I18nKey.TOS$ERROR_ACCEPTING), error);
toast.error(t(I18nKey.ERROR$GENERIC));
setIsSubmitting(false);
}
}
};
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="border border-tertiary p-8 rounded-lg max-w-md w-full flex flex-col gap-6 items-center">
<AllHandsLogo width={68} height={46} />
<div className="flex flex-col gap-2 w-full items-center text-center">
<h1 className="text-2xl font-bold">
{t(I18nKey.TOS$ACCEPT_TERMS_OF_SERVICE)}
</h1>
<p className="text-sm text-gray-500">
{t(I18nKey.TOS$ACCEPT_TERMS_DESCRIPTION)}
</p>
</div>
<TOSCheckbox onChange={() => setIsTosAccepted((prev) => !prev)} />
<BrandButton
isDisabled={!isTosAccepted || isSubmitting}
type="button"
variant="primary"
onClick={handleAcceptTOS}
className="w-full"
>
{isSubmitting ? t(I18nKey.HOME$LOADING) : t(I18nKey.TOS$CONTINUE)}
</BrandButton>
</div>
</div>
);
}
+2
View File
@@ -310,6 +310,7 @@ function AccountSettings() {
label: agent,
})) || []
}
wrapperClassName="w-[680px]"
defaultSelectedKey={settings.AGENT}
isClearable={false}
/>
@@ -502,6 +503,7 @@ function AccountSettings() {
label: language.label,
}))}
defaultSelectedKey={settings.LANGUAGE}
wrapperClassName="w-[680px]"
isClearable={false}
/>
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
function ApiKeysScreen() {
return (
<div className="flex flex-col grow overflow-auto p-11">
<ApiKeysManager />
</div>
);
}
export default ApiKeysScreen;
+1 -13
View File
@@ -1,25 +1,13 @@
import { redirect, useSearchParams } from "react-router";
import { useSearchParams } from "react-router";
import React from "react";
import { useTranslation } from "react-i18next";
import { PaymentForm } from "#/components/features/payment/payment-form";
import { GetConfigResponse } from "#/api/open-hands.types";
import { queryClient } from "#/entry.client";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
export const clientLoader = async () => {
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) {
return redirect("/settings");
}
return null;
};
function BillingSettingsScreen() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
+22 -55
View File
@@ -1,68 +1,35 @@
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setReplayJson } from "#/state/initial-query-slice";
import { useGitUser } from "#/hooks/query/use-git-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { ReplaySuggestionBox } from "#/components/features/suggestions/replay-suggestion-box";
import { GitRepositoriesSuggestionBox } from "#/components/features/git/git-repositories-suggestion-box";
import { CodeNotInGitLink } from "#/components/features/git/code-not-in-github-link";
import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
import { convertFileToText } from "#/utils/convert-file-to-text";
import { ENABLE_TRAJECTORY_REPLAY } from "#/utils/feature-flags";
import { PrefetchPageLinks } from "react-router";
import { HomeHeader } from "#/components/features/home/home-header";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { useAuth } from "#/context/auth-context";
function Home() {
const dispatch = useDispatch();
const formRef = React.useRef<HTMLFormElement>(null);
<PrefetchPageLinks page="/conversations/:conversationId" />;
const { data: config } = useConfig();
const { data: user } = useGitUser();
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config?.APP_MODE || null,
gitHubClientId: config?.GITHUB_CLIENT_ID || null,
});
function HomeScreen() {
const { providersAreSet } = useAuth();
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
string | null
>(null);
return (
<div
data-testid="home-screen"
className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
className="bg-base-secondary h-full flex flex-col rounded-xl px-[42px] pt-[42px] gap-8 overflow-y-auto"
>
<HeroHeading />
<div className="flex flex-col gap-1 w-full mt-8 md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<HomeHeader />
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<GitRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
/>
{ENABLE_TRAJECTORY_REPLAY() && (
<ReplaySuggestionBox
onChange={async (event) => {
if (event.target.files) {
const json = event.target.files[0];
dispatch(setReplayJson(await convertFileToText(json)));
posthog.capture("json_file_uploaded");
formRef.current?.requestSubmit();
} else {
// TODO: handle error
}
}}
/>
)}
</div>
<div className="w-full flex justify-start mt-2 ml-2">
<CodeNotInGitLink />
</div>
</div>
<hr className="border-[#717888]" />
<main className="flex justify-between gap-4">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
</main>
</div>
);
}
export default Home;
export default HomeScreen;
+55 -28
View File
@@ -58,6 +58,7 @@ export function ErrorBoundary() {
export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const isOnTosPage = pathname === "/accept-tos";
const [searchParams] = useSearchParams();
const { data: settings } = useSettings();
const { error, isFetching } = useBalance();
@@ -71,49 +72,75 @@ export default function MainApp() {
isError: authError,
} = useIsAuthed();
// Always call the hook, but we'll only use the result when not on TOS page
const gitHubAuthUrl = useGitHubAuthUrl({
appMode: config.data?.APP_MODE || null,
gitHubClientId: config.data?.GITHUB_CLIENT_ID || null,
});
// When on TOS page, we don't use the GitHub auth URL
const effectiveGitHubAuthUrl = isOnTosPage ? null : gitHubAuthUrl;
const [consentFormIsOpen, setConsentFormIsOpen] = React.useState(false);
React.useEffect(() => {
if (settings?.LANGUAGE) {
// Don't change language when on TOS page
if (!isOnTosPage && settings?.LANGUAGE) {
i18n.changeLanguage(settings.LANGUAGE);
}
}, [settings?.LANGUAGE]);
}, [settings?.LANGUAGE, isOnTosPage]);
React.useEffect(() => {
const consentFormModalIsOpen =
settings?.USER_CONSENTS_TO_ANALYTICS === null;
// Don't show consent form when on TOS page
if (!isOnTosPage) {
const consentFormModalIsOpen =
settings?.USER_CONSENTS_TO_ANALYTICS === null;
setConsentFormIsOpen(consentFormModalIsOpen);
}, [settings]);
React.useEffect(() => {
// Migrate user consent to the server if it was previously stored in localStorage
migrateUserConsent({
handleAnalyticsWasPresentInLocalStorage: () => {
setConsentFormIsOpen(false);
},
});
}, []);
React.useEffect(() => {
// Don't allow users to use the app if it 402s
if (error?.status === 402 && pathname !== "/") {
navigate("/");
} else if (!isFetching && searchParams.get("free_credits") === "success") {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
searchParams.delete("free_credits");
navigate("/");
setConsentFormIsOpen(consentFormModalIsOpen);
}
}, [error?.status, pathname, isFetching]);
}, [settings, isOnTosPage]);
const userIsAuthed = !!isAuthed && !authError;
React.useEffect(() => {
// Don't migrate user consent when on TOS page
if (!isOnTosPage) {
// Migrate user consent to the server if it was previously stored in localStorage
migrateUserConsent({
handleAnalyticsWasPresentInLocalStorage: () => {
setConsentFormIsOpen(false);
},
});
}
}, [isOnTosPage]);
React.useEffect(() => {
// Don't do any redirects when on TOS page
if (!isOnTosPage) {
// Don't allow users to use the app if it 402s
if (error?.status === 402 && pathname !== "/") {
navigate("/");
} else if (
!isFetching &&
searchParams.get("free_credits") === "success"
) {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
searchParams.delete("free_credits");
navigate("/");
}
}
}, [error?.status, pathname, isFetching, isOnTosPage]);
// When on TOS page, we don't make any API calls, so we need to handle this case
const userIsAuthed = isOnTosPage ? false : !!isAuthed && !authError;
// Only show the auth modal if:
// 1. User is not authenticated
// 2. We're not currently on the TOS page
// 3. We're in SaaS mode
const renderAuthModal =
!isFetchingAuth && !userIsAuthed && config.data?.APP_MODE === "saas";
!isFetchingAuth &&
!userIsAuthed &&
!isOnTosPage &&
config.data?.APP_MODE === "saas";
return (
<div
@@ -131,7 +158,7 @@ export default function MainApp() {
{renderAuthModal && (
<AuthModal
githubAuthUrl={gitHubAuthUrl}
githubAuthUrl={effectiveGitHubAuthUrl}
appMode={config.data?.APP_MODE}
/>
)}
+2 -2
View File
@@ -9,7 +9,6 @@ function SettingsScreen() {
const { t } = useTranslation();
const { data: config } = useConfig();
const isSaas = config?.APP_MODE === "saas";
const billingIsEnabled = config?.FEATURE_FLAGS.ENABLE_BILLING;
return (
<main
@@ -21,7 +20,7 @@ function SettingsScreen() {
<h1 className="text-sm leading-6">{t(I18nKey.SETTINGS$TITLE)}</h1>
</header>
{isSaas && billingIsEnabled && (
{isSaas && (
<nav
data-testid="settings-navbar"
className="flex items-end gap-12 px-11 border-b border-tertiary"
@@ -29,6 +28,7 @@ function SettingsScreen() {
{[
{ to: "/settings", text: "Account" },
{ to: "/settings/billing", text: "Credits" },
{ to: "/settings/api-keys", text: "API Keys" },
].map(({ to, text }) => (
<NavLink
end
+7 -1
View File
@@ -252,7 +252,13 @@ export const chatSlice = createSlice({
// Set success property based on observation type
if (observationID === "run") {
const commandObs = observation.payload as CommandObservation;
causeMessage.success = commandObs.extras.metadata.exit_code === 0;
// If exit_code is -1, it means the command timed out, so we set success to undefined
// to not show any status indicator
if (commandObs.extras.metadata.exit_code === -1) {
causeMessage.success = undefined;
} else {
causeMessage.success = commandObs.extras.metadata.exit_code === 0;
}
} else if (observationID === "run_ipython") {
// For IPython, we consider it successful if there's no error message
const ipythonObs = observation.payload as IPythonObservation;
+8
View File
@@ -5,3 +5,11 @@
.button-base {
@apply bg-tertiary border border-neutral-600 rounded;
}
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.skeleton {
@apply bg-gray-400 rounded-md animate-pulse;
}
@@ -0,0 +1,28 @@
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
/**
* Groups suggested tasks by their repository.
* @param tasks Array of suggested tasks
* @returns Array of suggested task groups
*/
export function groupSuggestedTasks(
tasks: SuggestedTask[],
): SuggestedTaskGroup[] {
const groupsMap: Record<string, SuggestedTaskGroup> = {};
for (const task of tasks) {
if (!groupsMap[task.repo]) {
groupsMap[task.repo] = {
title: task.repo,
tasks: [],
};
}
groupsMap[task.repo].tasks.push(task);
}
return Object.values(groupsMap);
}
+1 -1
View File
@@ -38,7 +38,7 @@ i18n.use(initReactI18next).init({
},
});
const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
export const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
configureStore({
reducer: rootReducer,
preloadedState,
@@ -95,20 +95,20 @@ class CodeActAgent(Agent):
self.response_to_actions_fn = codeact_function_calling.response_to_actions
def _get_tools(self) -> list[ChatCompletionToolParam]:
SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1']
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
use_simplified_tool_desc = False
use_short_tool_desc = False
if self.llm is not None:
use_simplified_tool_desc = any(
use_short_tool_desc = any(
model_substr in self.llm.config.model
for model_substr in SIMPLIFIED_TOOL_DESCRIPTION_LLM_SUBSTRS
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
)
tools = []
if self.config.enable_cmd:
tools.append(
create_cmd_run_tool(use_simplified_description=use_simplified_tool_desc)
)
tools.append(create_cmd_run_tool(use_short_description=use_short_tool_desc))
if self.config.enable_think:
tools.append(ThinkTool)
if self.config.enable_finish:
@@ -123,7 +123,7 @@ class CodeActAgent(Agent):
elif self.config.enable_editor:
tools.append(
create_str_replace_editor_tool(
use_simplified_description=use_simplified_tool_desc
use_short_description=use_short_tool_desc
)
)
return tools
@@ -199,7 +199,7 @@ def response_to_actions(response: ModelResponse) -> list[Action]:
# ================================================
elif tool_call.function.name.endswith(MCPClientTool.postfix()):
action = McpAction(
name=tool_call.function.name.rstrip(MCPClientTool.postfix()),
name=tool_call.function.name.removesuffix(MCPClientTool.postfix()),
arguments=tool_call.function.arguments,
)
else:
@@ -22,19 +22,17 @@ _DETAILED_BASH_DESCRIPTION = """Execute a bash command in the terminal within a
* Output truncation: If the output exceeds a maximum length, it will be truncated before being returned.
"""
_SIMPLIFIED_BASH_DESCRIPTION = """Execute a bash command in the terminal.
_SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together."""
def create_cmd_run_tool(
use_simplified_description: bool = False,
use_short_description: bool = False,
) -> ChatCompletionToolParam:
description = (
_SIMPLIFIED_BASH_DESCRIPTION
if use_simplified_description
else _DETAILED_BASH_DESCRIPTION
_SHORT_BASH_DESCRIPTION if use_short_description else _DETAILED_BASH_DESCRIPTION
)
return ChatCompletionToolParam(
type='function',
@@ -31,7 +31,7 @@ CRITICAL REQUIREMENTS FOR USING THIS TOOL:
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.
"""
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
_SHORT_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
* State is persistent across command calls and discussions with the user
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
* The `create` command cannot be used if the specified `path` already exists as a file
@@ -45,11 +45,11 @@ Notes for using the `str_replace` command:
def create_str_replace_editor_tool(
use_simplified_description: bool = False,
use_short_description: bool = False,
) -> ChatCompletionToolParam:
description = (
_SIMPLIFIED_STR_REPLACE_EDITOR_DESCRIPTION
if use_simplified_description
_SHORT_STR_REPLACE_EDITOR_DESCRIPTION
if use_short_description
else _DETAILED_STR_REPLACE_EDITOR_DESCRIPTION
)
return ChatCompletionToolParam(
+8 -7
View File
@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Any
from openhands.events.action import Action
@@ -9,7 +10,7 @@ class ActionParseError(Exception):
def __init__(self, error: str):
self.error = error
def __str__(self):
def __str__(self) -> str:
return self.error
@@ -20,16 +21,16 @@ class ResponseParser(ABC):
def __init__(
self,
):
) -> None:
# Need pay attention to the item order in self.action_parsers
self.action_parsers = []
self.action_parsers: list[ActionParser] = []
@abstractmethod
def parse(self, response: str) -> Action:
def parse(self, response: Any) -> Action:
"""Parses the action from the response from the LLM.
Parameters:
- response (str): The response from the LLM.
- response: The response from the LLM, which can be a string or a dictionary.
Returns:
- action (Action): The action parsed from the response.
@@ -37,11 +38,11 @@ class ResponseParser(ABC):
pass
@abstractmethod
def parse_response(self, response) -> str:
def parse_response(self, response: Any) -> str:
"""Parses the action from the response from the LLM.
Parameters:
- response (str): The response from the LLM.
- response: The response from the LLM, which can be a string or a dictionary.
Returns:
- action_str (str): The action str parsed from the response.

Some files were not shown because too many files have changed in this diff Show More