Compare commits

..

17 Commits

Author SHA1 Message Date
openhands
7a2e7d6270 Revert changes to pyproject.toml 2025-03-20 17:01:50 +00:00
openhands
f5c58adaf7 Refactor: Clean up WebSocket client code and fix logging 2025-03-20 16:49:38 +00:00
Xingyao Wang
c6cb025afe Merge branch 'main' into allow-message-during-client-loading 2025-03-20 12:41:16 -04:00
openhands
cb8214676e Simplify frontend logic by removing unnecessary backend readiness handling 2025-03-19 15:21:48 +00:00
openhands
fdfb7308b8 Move message queueing from frontend to backend 2025-03-19 15:06:16 +00:00
Xingyao Wang
4785de91b0 Merge branch 'main' into allow-message-during-client-loading 2025-03-19 10:59:12 -04:00
Xingyao Wang
effd2b7d06 Merge branch 'main' into allow-message-during-client-loading 2025-03-05 13:41:43 -05:00
openhands
608dd8f2c2 Fix linting issues 2025-03-04 01:59:59 +00:00
openhands
6d0c03509e Simplify backend ready detection and message sending 2025-03-03 22:39:10 +00:00
openhands
3e1070bbe9 Add enhanced logging to debug WebSocket connection and message queuing 2025-03-03 22:36:29 +00:00
openhands
2045350720 Fix message queuing by waiting for backend ready signal 2025-03-03 22:27:54 +00:00
openhands
5f83d4cf9a Add info method to EventLogger 2025-03-03 22:25:43 +00:00
openhands
d5a996a9e1 Update i18n declaration file 2025-02-28 02:27:47 +00:00
openhands
b0d38bbeb8 Merge main into allow-message-during-client-loading and resolve conflicts 2025-02-28 02:26:10 +00:00
openhands
ed50b3ee8f Fix agent response by sending agent state change event 2025-02-27 17:31:17 +00:00
openhands
4e5ed36213 Fix linting issue with queueMessage function order 2025-02-27 16:36:05 +00:00
openhands
9060452af6 Allow sending messages while client is connecting 2025-02-27 16:21:30 +00:00
165 changed files with 2001 additions and 2731 deletions

View File

@@ -33,7 +33,6 @@ Frontend:
- Testing:
- Run tests: `npm run test`
- To run specific tests: `npm run test -- -t "TestName"`
- Our test framework is vitest
- Building:
- Build for production: `npm run build`
- Environment Variables:
@@ -41,8 +40,3 @@ Frontend:
- Available variables: VITE_BACKEND_HOST, VITE_USE_TLS, VITE_INSECURE_SKIP_VERIFY, VITE_FRONTEND_PORT
- Internationalization:
- Generate i18n declaration file: `npm run make-i18n`
## Template for Github Pull Request
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.

View File

@@ -1,5 +0,0 @@
#! /bin/bash
echo "Setting up the environment..."
python -m pip install pre-commit

View File

@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.30-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.29-nikolaik`
## Develop inside Docker container

View File

@@ -43,23 +43,19 @@ See the [Running OpenHands](https://docs.all-hands.dev/modules/usage/installatio
system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.29
```
> [!WARNING]
> On a public network? See our [Hardened Docker Installation](https://docs.all-hands.dev/modules/usage/runtimes/docker#hardened-docker-installation) guide
> to secure your deployment by restricting network binding and implementing additional security measures.
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
Finally, you'll need a model provider and API key.

View File

@@ -42,10 +42,6 @@ workspace_base = "./workspace"
# If it's a folder, the session id will be used as the file name
#save_trajectory_path="./trajectories"
# Whether to save screenshots in the trajectory
# The screenshots are encoded and can make trajectory json files very large
#save_screenshots_in_trajectory = false
# Path to replay a trajectory, must be a file path
# If provided, trajectory will be loaded and replayed before the
# agent responds to any user instruction

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.30-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.29-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

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.30-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.29-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:

View File

@@ -47,7 +47,6 @@ docker run -it \
...
```
### Referring to UI Elements
When referencing UI elements, use ``.

View File

@@ -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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.cli
```

View File

@@ -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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -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.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.29
```
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).

View File

@@ -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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.cli
```

View File

@@ -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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
```

View File

@@ -11,16 +11,16 @@
在 Docker 中运行 OpenHands 是最简单的方式。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.29
```
你也可以在可脚本化的[无头模式](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)。

View File

@@ -11,7 +11,7 @@
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```

View File

@@ -1,24 +0,0 @@
# Repository Customization
You can customize how OpenHands works with your repository by creating a
`.openhands` directory at the root level.
## Microagents
You can use microagents to extend the OpenHands prompts with information
about your project and how you want OpenHands to work. See
[Repository Microagents](../prompting/microagents-repo) for more information.
## Setup Script
You can add `.openhands/setup.sh`, which will be run every time OpenHands begins
working with your repository. This is a good place to install dependencies, set
environment variables, etc.
For example:
```bash
#!/bin/bash
export MY_ENV_VAR="my value"
sudo apt-get update
sudo apt-get install -y lsof
cd frontend && npm install ; cd ..
```

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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.cli
```

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.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30 \
docker.all-hands.dev/all-hands-ai/openhands:0.29 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

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.30-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.30-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-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.30
docker.all-hands.dev/all-hands-ai/openhands:0.29
```
You'll find OpenHands running at http://localhost:3000!

View File

@@ -1,60 +0,0 @@
# OpenHands Feature Overview
![overview](https://www.all-hands.dev/assets/product/product-slide-1.webp)
## 1. Workspace
The Workspace feature provides a comprehensive development environment with the following key capabilities:
- File Explorer: Browse, view, and manage project files and directories
- Project Management: Import, create, and navigate between different projects
- Integrated Development Tools: Seamless integration with various development workflows
- File Operations:
* View file contents
* Create new files and folders
* Upload and download files
* Basic file manipulation
## 2. Jupyter Notebook
The Jupyter Notebook feature offers an interactive coding and data analysis environment:
- Interactive Code Cells: Execute Python code in a cell-based interface
- Input and Output Tracking: Maintain a history of code inputs and their corresponding outputs
- Persistent Session: Preserve code execution context between cells
- Supports various Python operations and data analysis tasks
- Real-time code execution and result visualization
## 3. Browser (Beta)
The Browser feature provides web interaction capabilities:
- Web Page Navigation: Open and browse websites within the application
- Screenshot Capture: Automatically generate screenshots of web pages
- Interaction Tools:
* Click elements
* Fill out forms
* Scroll pages
* Navigate through web content
- Supports 15 different browser interaction functions
## 4. Terminal
The Terminal feature offers a command-line interface within the application:
- Execute Shell Commands: Run bash and system commands
- Command History: Track and recall previous commands
- Environment Interaction: Interact directly with the system's command line
- Support for various programming and system administration tasks
## 5. Chat / AI Conversation
The Chat interface provides an AI-powered conversational experience:
- Interactive AI Assistant: Engage in natural language conversations
- Context-Aware Responses: AI understands and responds to development-related queries
- Action Suggestions: Provides actionable recommendations for tasks
- Conversation Management: Create, delete, and manage different conversation threads
## 6. App (Beta)
The main application interface combines all these features:
- Integrated Workspace: Seamless integration of workspace, browser, terminal, and AI chat
- Configurable Layout: Customize the arrangement of different feature panels
- State Management: Maintain context and state across different features
- Security and Privacy Controls: Manage application settings and permissions
### Additional Notes
- The application is currently in beta, with ongoing improvements and feature additions
- Supports various development workflows and AI-assisted coding
- Designed to enhance developer productivity through integrated tools and AI assistance

View File

@@ -10,7 +10,7 @@ OpenHands uses LiteLLM to make calls to Google's chat models. You can find their
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings:
- `LLM Provider` to `Gemini`
- `LLM Model` to the model you will be using.
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. gemini/<model-name> like `gemini/gemini-2.0-flash`).
If the model is not in the list, toggle `Advanced` options, and enter it in `Custom Model` (e.g. gemini/<model-name> like `gemini/gemini-1.5-pro`).
- `API Key` to your Gemini API key
## VertexAI - Google Cloud Platform Configs

View File

@@ -1,24 +0,0 @@
# Runtime Configuration
A Runtime is an environment where the OpenHands agent can edit files and run
commands.
By default, OpenHands uses a Docker-based runtime, running on your local computer.
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
We also support "remote" runtimes, which are typically managed by third-parties.
They can make setup a bit simpler and more scalable, especially
if you're running many OpenHands conversations in parallel (e.g. to do evaluation).
Additionally, we provide a "local" runtime that runs directly on your machine without Docker,
which can be useful in controlled environments like CI pipelines.
## Available Runtimes
OpenHands supports several different runtime environments:
- [Docker Runtime](./runtimes/docker.md) - The default runtime that uses Docker containers for isolation (recommended for most users)
- [OpenHands Remote Runtime](./runtimes/remote.md) - Cloud-based runtime for parallel execution (beta)
- [Modal Runtime](./runtimes/modal.md) - Runtime provided by our partners at Modal
- [Daytona Runtime](./runtimes/daytona.md) - Runtime provided by Daytona
- [Local Runtime](./runtimes/local.md) - Direct execution on your local machine without Docker

View File

@@ -1,8 +1,176 @@
---
title: Runtime Configuration
slug: /usage/runtimes
---
# Runtime Configuration
import { Redirect } from '@docusaurus/router';
A Runtime is an environment where the OpenHands agent can edit files and run
commands.
<Redirect to="/modules/usage/runtimes-index" />
By default, OpenHands uses a Docker-based runtime, running on your local computer.
This means you only have to pay for the LLM you're using, and your code is only ever sent to the LLM.
We also support "remote" runtimes, which are typically managed by third-parties.
They can make setup a bit simpler and more scalable, especially
if you're running many OpenHands conversations in parallel (e.g. to do evaluation).
Additionally, we provide a "local" runtime that runs directly on your machine without Docker,
which can be useful in controlled environments like CI pipelines.
## Docker Runtime
This is the default Runtime that's used when you start OpenHands. You might notice
some flags being passed to `docker run` that make this possible:
```
docker run # ...
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.29-nikolaik \
-v /var/run/docker.sock:/var/run/docker.sock \
# ...
```
The `SANDBOX_RUNTIME_CONTAINER_IMAGE` from nikolaik is a pre-built runtime image
that contains our Runtime server, as well as some basic utilities for Python and NodeJS.
You can also [build your own runtime image](how-to/custom-sandbox-guide).
### Connecting to Your filesystem
One useful feature here is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
1. Set `WORKSPACE_BASE`:
```bash
export WORKSPACE_BASE=/path/to/your/code
# Linux and Mac Example
# export WORKSPACE_BASE=$HOME/OpenHands
# Will set $WORKSPACE_BASE to /home/<username>/OpenHands
#
# WSL on Windows Example
# export WORKSPACE_BASE=/mnt/c/dev/OpenHands
# Will set $WORKSPACE_BASE to C:\dev\OpenHands
```
2. Add the following options to the `docker run` command:
```bash
docker run # ...
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
# ...
```
Be careful! There's nothing stopping the OpenHands agent from deleting or modifying
any files that are mounted into its workspace.
This setup can cause some issues with file permissions (hence the `SANDBOX_USER_ID` variable)
but seems to work well on most systems.
## OpenHands Remote Runtime
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes in parallel in the cloud.
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.
## Modal Runtime
Our partners at [Modal](https://modal.com/) have also provided a runtime for OpenHands.
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
You'll then need to set the following environment variables when starting OpenHands:
```bash
docker run # ...
-e RUNTIME=modal \
-e MODAL_API_TOKEN_ID="your-id" \
-e MODAL_API_TOKEN_SECRET="your-secret" \
```
## Daytona Runtime
Another option is using [Daytona](https://www.daytona.io/) as a runtime provider:
### Step 1: Retrieve Your Daytona API Key
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
2. Click **"Create Key"**.
3. Enter a name for your key and confirm the creation.
4. Once the key is generated, copy it.
### Step 2: Set Your API Key as an Environment Variable
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
This step ensures that OpenHands can authenticate with the Daytona platform when it runs.
### Step 3: Run OpenHands Locally Using Docker
To start the latest version of OpenHands on your machine, execute the following command in your terminal:
```bash
bash -i <(curl -sL https://get.daytona.io/openhands)
```
#### What This Command Does:
- Downloads the latest OpenHands release script.
- Runs the script in an interactive Bash session.
- Automatically pulls and runs the OpenHands container using Docker.
Once executed, OpenHands should be running locally and ready for use.
For more details and manual initialization, view the entire [README.md](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/impl/daytona/README.md)
## Local Runtime
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker. This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
:::caution
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
:::
### Prerequisites
Before using the Local Runtime, ensure you have the following dependencies installed:
1. You have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. tmux is available on your system.
### Configuration
To use the Local Runtime, besides required configurations like the model, API key, you'll need to set the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
- Via environment variables:
```bash
# Required
export RUNTIME=local
# Optional but recommended
export WORKSPACE_BASE=/path/to/your/workspace
```
- Via `config.toml`:
```toml
[core]
runtime = "local"
workspace_base = "/path/to/your/workspace"
```
If `WORKSPACE_BASE` is not set, the runtime will create a temporary directory for the agent to work in.
### Example Usage
Here's an example of how to start OpenHands with the Local Runtime in Headless Mode:
```bash
# Set the runtime type to local
export RUNTIME=local
# Optionally set a workspace directory
export WORKSPACE_BASE=/path/to/your/project
# Start OpenHands
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
```
### Use Cases
The Local Runtime is particularly useful for:
- CI/CD pipelines where Docker is not available.
- Testing and development of OpenHands itself.
- Environments where container usage is restricted.
- Scenarios where direct file system access is required.

View File

@@ -1,32 +0,0 @@
# Daytona Runtime
You can use [Daytona](https://www.daytona.io/) as a runtime provider:
## Step 1: Retrieve Your Daytona API Key
1. Visit the [Daytona Dashboard](https://app.daytona.io/dashboard/keys).
2. Click **"Create Key"**.
3. Enter a name for your key and confirm the creation.
4. Once the key is generated, copy it.
## Step 2: Set Your API Key as an Environment Variable
Run the following command in your terminal, replacing `<your-api-key>` with the actual key you copied:
```bash
export DAYTONA_API_KEY="<your-api-key>"
```
This step ensures that OpenHands can authenticate with the Daytona platform when it runs.
## Step 3: Run OpenHands Locally Using Docker
To start the latest version of OpenHands on your machine, execute the following command in your terminal:
```bash
bash -i <(curl -sL https://get.daytona.io/openhands)
```
### What This Command Does:
- Downloads the latest OpenHands release script.
- Runs the script in an interactive Bash session.
- Automatically pulls and runs the OpenHands container using Docker.
Once executed, OpenHands should be running locally and ready for use.
For more details and manual initialization, view the entire [README.md](https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/impl/daytona/README.md)

View File

@@ -1,88 +0,0 @@
# Docker Runtime
This is the default Runtime that's used when you start OpenHands.
## Image
The `SANDBOX_RUNTIME_CONTAINER_IMAGE` from nikolaik is a pre-built runtime image
that contains our Runtime server, as well as some basic utilities for Python and NodeJS.
You can also [build your own runtime image](../how-to/custom-sandbox-guide).
## Connecting to Your filesystem
One useful feature here is the ability to connect to your local filesystem. To mount your filesystem into the runtime:
1. Set `WORKSPACE_BASE`:
```bash
export WORKSPACE_BASE=/path/to/your/code
# Linux and Mac Example
# export WORKSPACE_BASE=$HOME/OpenHands
# Will set $WORKSPACE_BASE to /home/<username>/OpenHands
#
# WSL on Windows Example
# export WORKSPACE_BASE=/mnt/c/dev/OpenHands
# Will set $WORKSPACE_BASE to C:\dev\OpenHands
```
2. Add the following options to the `docker run` command:
```bash
docker run # ...
-e SANDBOX_USER_ID=$(id -u) \
-e WORKSPACE_MOUNT_PATH=$WORKSPACE_BASE \
-v $WORKSPACE_BASE:/opt/workspace_base \
# ...
```
Be careful! There's nothing stopping the OpenHands agent from deleting or modifying
any files that are mounted into its workspace.
This setup can cause some issues with file permissions (hence the `SANDBOX_USER_ID` variable)
but seems to work well on most systems.
## Hardened Docker Installation
When deploying OpenHands in environments where security is a priority, you should consider implementing a hardened Docker configuration. This section provides recommendations for securing your OpenHands Docker deployment beyond the default configuration.
### Security Considerations
The default Docker configuration in the README is designed for ease of use on a local development machine. If you're running on a public network (e.g. airport WiFi),
you should implement additional security measures.
### Network Binding Security
By default, OpenHands binds to all network interfaces (`0.0.0.0`), which can expose your instance to all networks the host is connected to. For a more secure setup:
1. **Restrict Network Binding**:
Use the `runtime_binding_address` configuration to restrict which network interfaces OpenHands listens on:
```bash
docker run # ...
-e SANDBOX_RUNTIME_BINDING_ADDRESS=127.0.0.1 \
# ...
```
This configuration ensures OpenHands only listens on the loopback interface (`127.0.0.1`), making it accessible only from the local machine.
2. **Secure Port Binding**:
Modify the `-p` flag to bind only to localhost instead of all interfaces:
```bash
docker run # ... \
-p 127.0.0.1:3000:3000 \
```
This ensures that the OpenHands web interface is only accessible from the local machine, not from other machines on the network.
### Network Isolation
Use Docker's network features to isolate OpenHands:
```bash
# Create an isolated network
docker network create openhands-network
# Run OpenHands in the isolated network
docker run # ... \
--network openhands-network \
```

View File

@@ -1,62 +0,0 @@
# Local Runtime
The Local Runtime allows the OpenHands agent to execute actions directly on your local machine without using Docker. This runtime is primarily intended for controlled environments like CI pipelines or testing scenarios where Docker is not available.
:::caution
**Security Warning**: The Local Runtime runs without any sandbox isolation. The agent can directly access and modify files on your machine. Only use this runtime in controlled environments or when you fully understand the security implications.
:::
## Prerequisites
Before using the Local Runtime, ensure that:
1. You have followed the [Development setup instructions](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
2. tmux is available on your system.
## Configuration
To use the Local Runtime, besides required configurations like the model, API key, you'll need to set the following options via environment variables or the [config.toml file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) when starting OpenHands:
- Via environment variables:
```bash
# Required
export RUNTIME=local
# Optional but recommended
export WORKSPACE_BASE=/path/to/your/workspace
```
- Via `config.toml`:
```toml
[core]
runtime = "local"
workspace_base = "/path/to/your/workspace"
```
If `WORKSPACE_BASE` is not set, the runtime will create a temporary directory for the agent to work in.
## Example Usage
Here's an example of how to start OpenHands with the Local Runtime in Headless Mode:
```bash
# Set the runtime type to local
export RUNTIME=local
# Optionally set a workspace directory
export WORKSPACE_BASE=/path/to/your/project
# Start OpenHands
poetry run python -m openhands.core.main -t "write a bash script that prints hi"
```
## Use Cases
The Local Runtime is particularly useful for:
- CI/CD pipelines where Docker is not available.
- Testing and development of OpenHands itself.
- Environments where container usage is restricted.
- Scenarios where direct file system access is required.

View File

@@ -1,13 +0,0 @@
# Modal Runtime
Our partners at [Modal](https://modal.com/) have provided a runtime for OpenHands.
To use the Modal Runtime, create an account, and then [create an API key.](https://modal.com/settings)
You'll then need to set the following environment variables when starting OpenHands:
```bash
docker run # ...
-e RUNTIME=modal \
-e MODAL_API_TOKEN_ID="your-id" \
-e MODAL_API_TOKEN_SECRET="your-secret" \
```

View File

@@ -1,6 +0,0 @@
# OpenHands Remote Runtime
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to launch runtimes in parallel in the cloud.
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
NOTE: This runtime is specifically designed for agent evaluation purposes only through [OpenHands evaluation harness](https://github.com/All-Hands-AI/OpenHands/tree/main/evaluation). It should not be used to launch production OpenHands applications.

View File

@@ -13,11 +13,6 @@ const sidebars: SidebarsConfig = {
label: 'Getting Started',
id: 'usage/getting-started',
},
{
type: 'doc',
label: 'Key Features',
id: 'usage/key-features',
},
{
type: 'category',
label: 'Prompting',
@@ -50,17 +45,6 @@ const sidebars: SidebarsConfig = {
},
],
},
{
type: 'category',
label: 'Customization',
items: [
{
type: 'doc',
label: 'Repository Customization',
id: 'usage/customization/repository',
},
],
},
{
type: 'category',
label: 'Usage Methods',
@@ -156,40 +140,9 @@ const sidebars: SidebarsConfig = {
],
},
{
type: 'category',
type: 'doc',
label: 'Runtime Configuration',
items: [
{
type: 'doc',
label: 'Overview',
id: 'usage/runtimes-index',
},
{
type: 'doc',
label: 'Docker Runtime',
id: 'usage/runtimes/docker',
},
{
type: 'doc',
label: 'Remote Runtime',
id: 'usage/runtimes/remote',
},
{
type: 'doc',
label: 'Modal Runtime',
id: 'usage/runtimes/modal',
},
{
type: 'doc',
label: 'Daytona Runtime',
id: 'usage/runtimes/daytona',
},
{
type: 'doc',
label: 'Local Runtime',
id: 'usage/runtimes/local',
},
],
id: 'usage/runtimes',
},
{
type: 'doc',

View File

@@ -53,7 +53,6 @@ fi
if [ -n "$AGENT_CONFIG" ]; then
echo "AGENT_CONFIG: $AGENT_CONFIG"
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
fi
# Run the command
eval $COMMAND

View File

@@ -573,7 +573,6 @@ def get_default_sandbox_config_for_eval() -> SandboxConfig:
# large enough timeout, since some testcases take very long to run
timeout=300,
api_key=os.environ.get('ALLHANDS_API_KEY', None),
runtime_startup_env_vars={'NO_CHANGE_TIMEOUT_SECONDS': '30'},
remote_runtime_api_url=os.environ.get('SANDBOX_REMOTE_RUNTIME_API_URL'),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,

View File

@@ -50,29 +50,27 @@ This will start the application in development mode. Open [http://localhost:3001
### Running the Application with the Actual Backend (Production Mode)
To run the application with the actual backend:
There are two ways to run the application with the actual backend:
```sh
# Build the application from the root directory
make build
# Start the application
make run
make start
```
Or to run backend and frontend seperately.
OR
```sh
# Start the backend from the root directory
make start-backend
# Serve the frontend
make start-frontend or
cd frontend && npm start -- --port 3001
```
# Build the frontend
cd frontend && npm run build
Start frontend with Mock Service Worker (MSW), see testing for more info.
```sh
npm run dev:mock or npm run dev:mock:saas
# Serve the frontend
npm start -- --port 3001
```
### Environment Variables
@@ -123,113 +121,12 @@ components
## Testing
### Testing Framework and Tools
We use `Vitest` for testing. To run the tests, run the following command:
We use the following testing tools:
- **Test Runner**: Vitest
- **Rendering**: React Testing Library
- **User Interactions**: @testing-library/user-event
- **API Mocking**: [Mock Service Worker (MSW)](https://mswjs.io/)
- **Code Coverage**: Vitest with V8 coverage
### Running Tests
To run all tests:
```sh
npm run test
```
To run tests with coverage:
```sh
npm run test:coverage
```
### Testing Best Practices
1. **Component Testing**
- Test components in isolation
- Use our custom [`renderWithProviders()`](https://github.com/All-Hands-AI/OpenHands/blob/ce26f1c6d3feec3eedf36f823dee732b5a61e517/frontend/test-utils.tsx#L56-L85) that wraps the components we want to test in our providers. It is especially useful for components that use Redux
- Use `render()` from React Testing Library to render components
- Prefer querying elements by role, label, or test ID over CSS selectors
- Test both rendering and interaction scenarios
2. **User Event Simulation**
- Use `userEvent` for simulating realistic user interactions
- Test keyboard events, clicks, typing, and other user actions
- Handle edge cases like disabled states, empty inputs, etc.
3. **Mocking**
- We test components that make network requests by mocking those requests with Mock Service Worker (MSW)
- Use `vi.fn()` to create mock functions for callbacks and event handlers
- Mock external dependencies and API calls (more info)[https://mswjs.io/docs/getting-started]
- Verify mock function calls using `.toHaveBeenCalledWith()`, `.toHaveBeenCalledTimes()`
4. **Accessibility Testing**
- Use `toBeInTheDocument()` to check element presence
- Test keyboard navigation and screen reader compatibility
- Verify correct ARIA attributes and roles
5. **State and Prop Testing**
- Test component behavior with different prop combinations
- Verify state changes and conditional rendering
- Test error states and loading scenarios
6. **Internationalization (i18n) Testing**
- Test translation keys and placeholders
- Verify text rendering across different languages
Example Test Structure:
```typescript
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
describe("ComponentName", () => {
it("should render correctly", () => {
render(<Component />);
expect(screen.getByRole("button")).toBeInTheDocument();
});
it("should handle user interactions", async () => {
const mockCallback = vi.fn();
const user = userEvent.setup();
render(<Component onClick={mockCallback} />);
const button = screen.getByRole("button");
await user.click(button);
expect(mockCallback).toHaveBeenCalledOnce();
});
});
```
### Example Tests in the Codebase
For real-world examples of testing, check out these test files:
1. **Chat Input Component Test**:
[`__tests__/components/chat/chat-input.test.tsx`](https://github.com/All-Hands-AI/OpenHands/blob/main/frontend/__tests__/components/chat/chat-input.test.tsx)
- Demonstrates comprehensive testing of a complex input component
- Covers various scenarios like submission, disabled states, and user interactions
2. **File Explorer Component Test**:
[`__tests__/components/file-explorer/file-explorer.test.tsx`](https://github.com/All-Hands-AI/OpenHands/blob/main/frontend/__tests__/components/file-explorer/file-explorer.test.tsx)
- Shows testing of a more complex component with multiple interactions
- Illustrates testing of nested components and state management
### Test Coverage
- Aim for high test coverage, especially for critical components
- Focus on testing different scenarios and edge cases
- Use code coverage reports to identify untested code paths
### Continuous Integration
Tests are automatically run during:
- Pre-commit hooks
- Pull request checks
- CI/CD pipeline
## Contributing
Please read the [CONTRIBUTING.md](../CONTRIBUTING.md) file for details on our code of conduct, and the process for submitting pull requests to us.

View File

@@ -1,9 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router";
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
import OpenHands from "#/api/open-hands";
import { vi } from "vitest"
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
@@ -49,7 +48,7 @@ describe("ExpandableMessage", () => {
id="OBSERVATION_MESSAGE$RUN"
message="Command executed successfully"
type="action"
success
success={true}
/>,
);
const element = screen.getByText("OBSERVATION_MESSAGE$RUN");
@@ -94,31 +93,4 @@ describe("ExpandableMessage", () => {
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
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
const RouterStub = createRoutesStub([
{
Component: () => (
<ExpandableMessage
id="STATUS$ERROR_LLM_OUT_OF_CREDITS"
message=""
type=""
/>
),
path: "/",
},
]);
renderWithProviders(<RouterStub />);
await screen.findByTestId("out-of-credits");
});
});

View File

@@ -4,6 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands";
import { SettingsProvider } from "#/context/settings-context";
import { AuthProvider } from "#/context/auth-context";
describe("AnalyticsConsentFormModal", () => {
@@ -16,7 +17,7 @@ describe("AnalyticsConsentFormModal", () => {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
<SettingsProvider>{children}</SettingsProvider>
</QueryClientProvider>
</AuthProvider>
),

View File

@@ -1,4 +1,4 @@
import { screen, within } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import {
afterAll,
afterEach,
@@ -10,7 +10,6 @@ import {
vi,
} from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
@@ -19,13 +18,10 @@ describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
const onChangeTitle = vi.fn();
const onDownloadWorkspace = vi.fn();
beforeAll(() => {
vi.stubGlobal("window", {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal("window", { open: vi.fn() });
});
afterEach(() => {
@@ -37,7 +33,7 @@ describe("ConversationCard", () => {
});
it("should render the conversation card", () => {
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -56,7 +52,7 @@ describe("ConversationCard", () => {
});
it("should render the selectedRepository if available", () => {
const { rerender } = renderWithProviders(
const { rerender } = render(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -87,7 +83,7 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -112,7 +108,7 @@ describe("ConversationCard", () => {
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -136,7 +132,7 @@ describe("ConversationCard", () => {
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -157,7 +153,7 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -186,7 +182,7 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -210,7 +206,7 @@ describe("ConversationCard", () => {
test("clicking the title should trigger the onClick handler", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
@@ -230,7 +226,7 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -251,7 +247,7 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -273,13 +269,14 @@ describe("ConversationCard", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("should show display cost button only when showOptions is true", async () => {
it("should call onDownloadWorkspace when the download button is clicked", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -289,64 +286,17 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
showOptions
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
});
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
const downloadButton = within(menu).getByTestId("download-button");
await user.click(displayCostButton);
await user.click(downloadButton);
// Verify if metrics modal is displayed by checking for the modal content
expect(screen.getByText("Metrics Information")).toBeInTheDocument();
expect(onDownloadWorkspace).toHaveBeenCalled();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
const { rerender } = renderWithProviders(
const { rerender } = render(
<ConversationCard
onClick={onClick}
onChangeTitle={onChangeTitle}
@@ -359,9 +309,8 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("edit-button")).toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
@@ -377,19 +326,18 @@ describe("ConversationCard", () => {
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),
).not.toBeInTheDocument();
expect(within(newMenu).queryByTestId("delete-button")).toBeInTheDocument();
expect(screen.queryByTestId("edit-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
});
it("should not render the ellipsis button if there are no actions", () => {
const { rerender } = renderWithProviders(
const { rerender } = render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -402,6 +350,7 @@ describe("ConversationCard", () => {
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -410,6 +359,18 @@ describe("ConversationCard", () => {
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
@@ -424,7 +385,7 @@ describe("ConversationCard", () => {
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive
@@ -439,7 +400,7 @@ describe("ConversationCard", () => {
});
it("should render the other indicators when provided", () => {
renderWithProviders(
render(
<ConversationCard
onDelete={onDelete}
isActive

View File

@@ -1,4 +1,4 @@
import { screen, waitFor, within } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
@@ -13,7 +13,6 @@ import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
import { renderWithProviders } from "test-utils";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -25,13 +24,14 @@ describe("ConversationPanel", () => {
]);
const renderConversationPanel = (config?: QueryClientConfig) =>
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
cost: null,
usage: null
}
}
render(<RouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(config)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
const { endSessionMock } = vi.hoisted(() => ({
@@ -53,38 +53,9 @@ describe("ConversationPanel", () => {
}));
});
const mockConversations = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([...mockConversations]);
});
it("should render the conversations", async () => {
@@ -112,7 +83,13 @@ describe("ConversationPanel", () => {
new Error("Failed to fetch conversations"),
);
renderConversationPanel();
renderConversationPanel({
defaultOptions: {
queries: {
retry: false,
},
},
});
const error = await screen.findByText("Failed to fetch conversations");
expect(error).toBeInTheDocument();
@@ -147,20 +124,6 @@ describe("ConversationPanel", () => {
it("should call endSession after deleting a conversation that is the current session", async () => {
const user = userEvent.setup();
const mockData = [...mockConversations];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation");
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
// Wait for React Query to update its cache
await new Promise(resolve => setTimeout(resolve, 0));
});
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
@@ -177,60 +140,18 @@ describe("ConversationPanel", () => {
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Wait for the cards to update with a longer timeout
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
}, { timeout: 2000 });
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
expect(endSessionMock).toHaveBeenCalledOnce();
});
it("should delete a conversation", async () => {
const user = userEvent.setup();
const mockData = [
{
conversation_id: "1",
title: "Conversation 1",
selected_repository: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "2",
title: "Conversation 2",
selected_repository: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STOPPED" as const,
},
{
conversation_id: "3",
title: "Conversation 3",
selected_repository: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation");
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
});
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
@@ -244,11 +165,9 @@ describe("ConversationPanel", () => {
expect(screen.queryByText("Confirm")).not.toBeInTheDocument();
// Wait for the cards to update
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
});
// Ensure the conversation is deleted
cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(1);
});
it("should rename a conversation", async () => {
@@ -270,7 +189,7 @@ describe("ConversationPanel", () => {
await user.tab();
// Ensure the conversation is renamed
expect(updateUserConversationSpy).toHaveBeenCalledWith("1", {
expect(updateUserConversationSpy).toHaveBeenCalledWith("3", {
title: "Conversation 1 Renamed",
});
});
@@ -295,7 +214,7 @@ describe("ConversationPanel", () => {
// Ensure the conversation is not renamed
expect(updateUserConversationSpy).not.toHaveBeenCalled();
await clickOnEditButton(user, card);
await clickOnEditButton(user);
await user.type(title, "Conversation 1");
await user.click(title);
@@ -310,21 +229,17 @@ describe("ConversationPanel", () => {
});
it("should call onClose after clicking a card", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
const firstCard = cards[1];
await user.click(firstCard);
await userEvent.click(firstCard);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should refetch data on rerenders", async () => {
const user = userEvent.setup();
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue([...mockConversations]);
// We need to simulate the toggling of the component to test the refetching
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
return (
@@ -344,28 +259,25 @@ describe("ConversationPanel", () => {
},
]);
renderWithProviders(<MyRouterStub />, {
preloadedState: {
metrics: {
cost: null,
usage: null
}
}
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
render(<MyRouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
const toggleButton = screen.getByText("Toggle");
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
// Initial render
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
const button = screen.getByText("Toggle");
await userEvent.click(button);
await userEvent.click(button);
// Toggle off
await user.click(toggleButton);
expect(screen.queryByTestId("conversation-card")).not.toBeInTheDocument();
// Toggle on
await user.click(toggleButton);
const newCards = await screen.findAllByTestId("conversation-card");
expect(newCards).toHaveLength(3);
await waitFor(() =>
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
);
});
});

View File

@@ -30,10 +30,6 @@ describe("GitHubRepositorySelector", () => {
APP_SLUG: "openhands",
GITHUB_CLIENT_ID: "test-client-id",
POSTHOG_CLIENT_KEY: "test-posthog-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderWithProviders(

View File

@@ -4,8 +4,10 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import OpenHands from "#/api/open-hands";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as featureFlags from "#/utils/feature-flags";
describe("PaymentForm", () => {
const billingSettingsSpy = vi.spyOn(featureFlags, "BILLING_SETTINGS");
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
@@ -20,16 +22,13 @@ describe("PaymentForm", () => {
});
beforeEach(() => {
// useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
// useBalance hook will return the balance only if the APP_MODE is "saas"
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
billingSettingsSpy.mockReturnValue(true);
});
afterEach(() => {

View File

@@ -59,7 +59,7 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when export button is clicked", async () => {
it("should call onExportTrajectory when negative feedback is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}

View File

@@ -1,12 +1,9 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import * as ChatSlice from "#/state/chat-slice";
import {
updateStatusWhenErrorMessagePresent,
WsClientProvider,
useWsClient,
} from "#/context/ws-client-provider";
import React from "react";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
@@ -44,59 +41,3 @@ describe("Propagate error message", () => {
});
});
});
// Create a mock for socket.io-client
const mockEmit = vi.fn();
const mockOn = vi.fn();
const mockOff = vi.fn();
const mockDisconnect = vi.fn();
vi.mock("socket.io-client", () => {
return {
io: vi.fn(() => ({
emit: mockEmit,
on: mockOn,
off: mockOff,
disconnect: mockDisconnect,
io: {
opts: {
query: {},
},
},
})),
};
});
// Mock component to test the hook
const TestComponent = () => {
const { send } = useWsClient();
React.useEffect(() => {
// Send a test event
send({ type: "test_event" });
}, [send]);
return <div>Test Component</div>;
};
describe("WsClientProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should emit oh_user_action event when send is called", async () => {
const { getByText } = render(
<WsClientProvider conversationId="test-conversation-id">
<TestComponent />
</WsClientProvider>
);
// Assert
expect(getByText("Test Component")).toBeInTheDocument();
// Wait for the emit call to happen (useEffect needs time to run)
await waitFor(() => {
expect(mockEmit).toHaveBeenCalledWith("oh_user_action", { type: "test_event" });
}, { timeout: 1000 });
});
});

View File

@@ -3,18 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { AuthProvider } from "#/context/auth-context";
describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});

View File

@@ -55,8 +55,7 @@ describe("frontend/routes/_oh", () => {
});
});
// FIXME: This test fails when it shouldn't be, please investigate
it.skip("should render and capture the user's consent if oss mode", async () => {
it("should render and capture the user's consent if oss mode", async () => {
const user = userEvent.setup();
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
@@ -69,10 +68,6 @@ describe("frontend/routes/_oh", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
// @ts-expect-error - We only care about the user_consents_to_analytics field
@@ -104,10 +99,6 @@ describe("frontend/routes/_oh", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderWithProviders(<RouteStub />);

View File

@@ -8,6 +8,7 @@ import MainApp from "#/routes/_oh/route";
import SettingsScreen from "#/routes/settings";
import Home from "#/routes/_oh._index/route";
import OpenHands from "#/api/open-hands";
import * as FeatureFlags from "#/utils/feature-flags";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
@@ -51,8 +52,6 @@ afterEach(() => {
});
describe("Home Screen", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
it("should render the home screen", () => {
renderWithProviders(<RouterStub initialEntries={["/"]} />);
});
@@ -69,14 +68,6 @@ describe("Home Screen", () => {
});
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={["/"]} />);
@@ -128,14 +119,10 @@ describe("Settings 404", () => {
});
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
// TODO: Remove HIDE_LLM_SETTINGS check once released
vi.spyOn(FeatureFlags, "HIDE_LLM_SETTINGS").mockReturnValue(true);
// @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,
},
});
getConfigSpy.mockResolvedValue({ APP_MODE: "saas" });
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
@@ -159,19 +146,14 @@ describe("Setup Payment modal", () => {
// @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,
},
});
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
const error = createAxiosNotFoundErrorObject();
getSettingsSpy.mockRejectedValue(error);
renderWithProviders(<RouterStub initialEntries={["/"]} />);
const setupPaymentModal = await screen.findByTestId(
"proceed-to-stripe-button",
);
const setupPaymentModal = await screen.findByTestId("proceed-to-stripe-button");
expect(setupPaymentModal).toBeInTheDocument();
});
});

View File

@@ -6,9 +6,11 @@ import { renderWithProviders } from "test-utils";
import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
import * as FeatureFlags from "#/utils/feature-flags";
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
vi.spyOn(FeatureFlags, "BILLING_SETTINGS").mockReturnValue(true);
const RoutesStub = createRoutesStub([
{
@@ -35,10 +37,6 @@ describe("Settings Billing", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -54,10 +52,6 @@ describe("Settings Billing", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -75,10 +69,6 @@ describe("Settings Billing", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();

View File

@@ -1,6 +1,15 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
test,
vi,
} from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent, { UserEvent } from "@testing-library/user-event";
import OpenHands from "#/api/open-hands";
@@ -11,6 +20,7 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { PostApiSettings } from "#/types/settings";
import * as ConsentHandlers from "#/utils/handle-capture-consent";
import AccountSettings from "#/routes/account-settings";
import * as FeatureFlags from "#/utils/feature-flags";
const toggleAdvancedSettings = async (user: UserEvent) => {
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
@@ -29,6 +39,11 @@ describe("Settings Screen", () => {
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
}));
beforeAll(() => {
// TODO: Remove this once we release
vi.spyOn(FeatureFlags, "HIDE_LLM_SETTINGS").mockReturnValue(true);
});
afterEach(() => {
vi.clearAllMocks();
});
@@ -72,10 +87,6 @@ describe("Settings Screen", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
});
@@ -112,7 +123,7 @@ describe("Settings Screen", () => {
});
});
it("should set '<hidden>' placeholder if the GitHub token is set", async () => {
it("should set asterik placeholder if the GitHub token is set", async () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
github_token_is_set: true,
@@ -122,7 +133,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
const input = screen.getByTestId("github-token-input");
expect(input).toHaveProperty("placeholder", "<hidden>");
expect(input).toHaveProperty("placeholder", "**********");
});
});
@@ -195,10 +206,6 @@ describe("Settings Screen", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -213,10 +220,6 @@ describe("Settings Screen", () => {
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
APP_SLUG: "test-app",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -228,10 +231,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -309,10 +308,6 @@ describe("Settings Screen", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
});
@@ -410,7 +405,7 @@ describe("Settings Screen", () => {
});
});
it("should set '<hidden>' placeholder if the LLM API key is set", async () => {
it("should set asterik placeholder if the LLM API key is set", async () => {
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
llm_api_key: "**********",
@@ -420,7 +415,7 @@ describe("Settings Screen", () => {
await waitFor(() => {
const input = screen.getByTestId("llm-api-key-input");
expect(input).toHaveProperty("placeholder", "<hidden>");
expect(input).toHaveProperty("placeholder", "**********");
});
});
@@ -454,10 +449,6 @@ describe("Settings Screen", () => {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -472,10 +463,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -487,10 +474,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
getSettingsSpy.mockResolvedValue({
@@ -509,10 +492,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
renderSettingsScreen();
@@ -527,10 +506,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
getSettingsSpy.mockResolvedValue({
@@ -1007,10 +982,6 @@ describe("Settings Screen", () => {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: true,
},
});
});

View File

@@ -9,67 +9,67 @@ describe("formatTimeDelta", () => {
it("formats the yearly time correctly", () => {
const oneYearAgo = new Date("2023-01-01T00:00:00Z");
expect(formatTimeDelta(oneYearAgo)).toBe("1y");
expect(formatTimeDelta(oneYearAgo)).toBe("1 year");
const twoYearsAgo = new Date("2022-01-01T00:00:00Z");
expect(formatTimeDelta(twoYearsAgo)).toBe("2y");
expect(formatTimeDelta(twoYearsAgo)).toBe("2 years");
const threeYearsAgo = new Date("2021-01-01T00:00:00Z");
expect(formatTimeDelta(threeYearsAgo)).toBe("3y");
expect(formatTimeDelta(threeYearsAgo)).toBe("3 years");
});
it("formats the monthly time correctly", () => {
const oneMonthAgo = new Date("2023-12-01T00:00:00Z");
expect(formatTimeDelta(oneMonthAgo)).toBe("1mo");
expect(formatTimeDelta(oneMonthAgo)).toBe("1 month");
const twoMonthsAgo = new Date("2023-11-01T00:00:00Z");
expect(formatTimeDelta(twoMonthsAgo)).toBe("2mo");
expect(formatTimeDelta(twoMonthsAgo)).toBe("2 months");
const threeMonthsAgo = new Date("2023-10-01T00:00:00Z");
expect(formatTimeDelta(threeMonthsAgo)).toBe("3mo");
expect(formatTimeDelta(threeMonthsAgo)).toBe("3 months");
});
it("formats the daily time correctly", () => {
const oneDayAgo = new Date("2023-12-31T00:00:00Z");
expect(formatTimeDelta(oneDayAgo)).toBe("1d");
expect(formatTimeDelta(oneDayAgo)).toBe("1 day");
const twoDaysAgo = new Date("2023-12-30T00:00:00Z");
expect(formatTimeDelta(twoDaysAgo)).toBe("2d");
expect(formatTimeDelta(twoDaysAgo)).toBe("2 days");
const threeDaysAgo = new Date("2023-12-29T00:00:00Z");
expect(formatTimeDelta(threeDaysAgo)).toBe("3d");
expect(formatTimeDelta(threeDaysAgo)).toBe("3 days");
});
it("formats the hourly time correctly", () => {
const oneHourAgo = new Date("2023-12-31T23:00:00Z");
expect(formatTimeDelta(oneHourAgo)).toBe("1h");
expect(formatTimeDelta(oneHourAgo)).toBe("1 hour");
const twoHoursAgo = new Date("2023-12-31T22:00:00Z");
expect(formatTimeDelta(twoHoursAgo)).toBe("2h");
expect(formatTimeDelta(twoHoursAgo)).toBe("2 hours");
const threeHoursAgo = new Date("2023-12-31T21:00:00Z");
expect(formatTimeDelta(threeHoursAgo)).toBe("3h");
expect(formatTimeDelta(threeHoursAgo)).toBe("3 hours");
});
it("formats the minute time correctly", () => {
const oneMinuteAgo = new Date("2023-12-31T23:59:00Z");
expect(formatTimeDelta(oneMinuteAgo)).toBe("1m");
expect(formatTimeDelta(oneMinuteAgo)).toBe("1 minute");
const twoMinutesAgo = new Date("2023-12-31T23:58:00Z");
expect(formatTimeDelta(twoMinutesAgo)).toBe("2m");
expect(formatTimeDelta(twoMinutesAgo)).toBe("2 minutes");
const threeMinutesAgo = new Date("2023-12-31T23:57:00Z");
expect(formatTimeDelta(threeMinutesAgo)).toBe("3m");
expect(formatTimeDelta(threeMinutesAgo)).toBe("3 minutes");
});
it("formats the second time correctly", () => {
const oneSecondAgo = new Date("2023-12-31T23:59:59Z");
expect(formatTimeDelta(oneSecondAgo)).toBe("1s");
expect(formatTimeDelta(oneSecondAgo)).toBe("1 second");
const twoSecondsAgo = new Date("2023-12-31T23:59:58Z");
expect(formatTimeDelta(twoSecondsAgo)).toBe("2s");
expect(formatTimeDelta(twoSecondsAgo)).toBe("2 seconds");
const threeSecondsAgo = new Date("2023-12-31T23:59:57Z");
expect(formatTimeDelta(threeSecondsAgo)).toBe("3s");
expect(formatTimeDelta(threeSecondsAgo)).toBe("3 seconds");
});
});

View File

@@ -0,0 +1,20 @@
import { vi } from "vitest";
import OpenHands from "#/api/open-hands";
export const setupTestConfig = () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
});
};
export const setupSaasTestConfig = () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
});
};

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.30.0",
"version": "0.29.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.30.0",
"version": "0.29.1",
"dependencies": {
"@heroui/react": "2.7.4",
"@monaco-editor/react": "^4.7.0-rc.0",

View File

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

View File

@@ -1,8 +1,7 @@
import axios from "axios";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
export const openHands = axios.create();
export const setAuthTokenHeader = (token: string) => {
openHands.defaults.headers.common.Authorization = `Bearer ${token}`;
};

View File

@@ -49,10 +49,6 @@ export interface GetConfigResponse {
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
};
}
export interface GetVSCodeUrlResponse {

View File

@@ -4,7 +4,7 @@ import {
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useCurrentSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { BrandButton } from "../settings/brand-button";
@@ -15,14 +15,14 @@ interface AnalyticsConsentFormModalProps {
export function AnalyticsConsentFormModal({
onClose,
}: AnalyticsConsentFormModalProps) {
const { mutate: saveUserSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const analytics = formData.get("analytics") === "on";
saveUserSettings(
await saveUserSettings(
{ user_consents_to_analytics: analytics },
{
onSuccess: () => {

View File

@@ -2,6 +2,7 @@ import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { DownloadModal } from "#/components/shared/download-modal";
import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
@@ -17,11 +18,21 @@ export function ActionSuggestions({
(state: RootState) => state.initialQuery,
);
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadClose = () => {
setIsDownloading(false);
};
return (
<div className="flex flex-col gap-2 mb-2">
{githubTokenIsSet && selectedRepository && (
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{githubTokenIsSet && selectedRepository ? (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
@@ -63,6 +74,21 @@ export function ActionSuggestions({
/>
)}
</div>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download files"
: "Downloading, please wait...",
value: "Download files",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
if (!isDownloading) {
setIsDownloading(true);
}
}}
/>
)}
</div>
);

View File

@@ -13,24 +13,31 @@ import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
import { TypingIndicator } from "./typing-indicator";
import { useWsClient } from "#/context/ws-client-provider";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { Messages } from "./messages";
import { ChatSuggestions } from "./chat-suggestions";
import { ActionSuggestions } from "./action-suggestions";
import { ContinueButton } from "#/components/shared/buttons/continue-button";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { downloadTrajectory } from "#/utils/download-files";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
function getEntryPoint(hasRepository: boolean | null): string {
function getEntryPoint(
hasRepository: boolean | null,
hasImportedProjectZip: boolean | null,
): string {
if (hasRepository) return "github";
if (hasImportedProjectZip) return "zip";
return "direct";
}
export function ChatInterface() {
const { send, isLoadingMessages } = useWsClient();
const { send, isLoadingMessages, status, pendingMessages } = useWsClient();
const dispatch = useDispatch();
const scrollRef = React.useRef<HTMLDivElement>(null);
const { scrollDomToBottom, onChatBodyScroll, hitBottom } =
@@ -44,17 +51,24 @@ export function ChatInterface() {
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const { selectedRepository } = useSelector(
const { selectedRepository, importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const params = useParams();
const { mutate: getTrajectory } = useGetTrajectory();
const isClientDisconnected = status === WsClientProviderStatus.DISCONNECTED;
const hasPendingMessages = pendingMessages.length > 0;
const handleSendMessage = async (content: string, files: File[]) => {
if (messages.length === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(selectedRepository !== null),
entry_point: getEntryPoint(
selectedRepository !== null,
importedProjectZip !== null,
),
query_character_length: content.length,
uploaded_zip_size: importedProjectZip?.length,
});
} else {
posthog.capture("user_message_sent", {
@@ -68,7 +82,15 @@ export function ChatInterface() {
const timestamp = new Date().toISOString();
const pending = true;
dispatch(addUserMessage({ content, imageUrls, timestamp, pending }));
send(createChatMessage(content, imageUrls, timestamp));
// Create and send the chat message
const chatMessage = createChatMessage(content, imageUrls, timestamp);
send(chatMessage);
// Send the agent state change event immediately
// The backend will handle the ordering and queueing
send(generateAgentStateChangeEvent(AgentState.RUNNING));
setMessageToSend(null);
};
@@ -77,6 +99,10 @@ export function ChatInterface() {
send(generateAgentStateChangeEvent(AgentState.STOPPED));
};
const handleSendContinueMsg = () => {
handleSendMessage("Continue", []);
};
const onClickShareFeedbackActionButton = async (
polarity: "positive" | "negative",
) => {
@@ -119,8 +145,20 @@ export function ChatInterface() {
className="flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2"
>
{isLoadingMessages && (
<div className="flex justify-center">
<div className="flex flex-col items-center gap-2">
<LoadingSpinner size="small" />
{isClientDisconnected && (
<div className="text-sm text-neutral-400">
Waiting for client to become ready...
{hasPendingMessages && (
<div className="text-xs text-neutral-500 mt-1">
{pendingMessages.length} message
{pendingMessages.length !== 1 ? "s" : ""} will be sent when
connected
</div>
)}
</div>
)}
</div>
)}
@@ -153,6 +191,10 @@ export function ChatInterface() {
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{messages.length > 2 &&
curAgentState === AgentState.AWAITING_USER_INPUT && (
<ContinueButton onClick={handleSendContinueMsg} />
)}
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
</div>
@@ -163,7 +205,7 @@ export function ChatInterface() {
onSubmit={handleSendMessage}
onStop={handleStop}
isDisabled={
curAgentState === AgentState.LOADING ||
// Allow input even when loading, but not during confirmation
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}

View File

@@ -11,6 +11,7 @@ import CheckCircle from "#/icons/check-circle-solid.svg?react";
import XCircle from "#/icons/x-circle-solid.svg?react";
import { cn } from "#/utils/utils";
import { useConfig } from "#/hooks/query/use-config";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
interface ExpandableMessageProps {
id?: string;
@@ -42,15 +43,12 @@ export function ExpandableMessage({
const statusIconClasses = "h-4 w-4 ml-2 inline";
if (
config?.FEATURE_FLAGS.ENABLE_BILLING &&
BILLING_SETTINGS() &&
config?.APP_MODE === "saas" &&
id === "STATUS$ERROR_LLM_OUT_OF_CREDITS"
) {
return (
<div
data-testid="out-of-credits"
className="flex gap-2 items-center justify-start border-l-2 pl-2 my-2 py-2 border-danger"
>
<div className="flex gap-2 items-center justify-start border-l-2 pl-2 my-2 py-2 border-danger">
<div className="text-sm w-full">
<div className="font-bold text-danger">
{t("STATUS$ERROR_LLM_OUT_OF_CREDITS")}

View File

@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
<ContextMenu
testId="account-settings-context-menu"
ref={ref}
className="absolute right-full md:left-full -top-1 z-10 w-fit"
className="absolute right-full md:left-full -top-1 z-10"
>
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}

View File

@@ -20,7 +20,7 @@ export function ContextMenuListItem({
disabled={isDisabled}
className={cn(
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent",
)}
>
{children}

View File

@@ -18,7 +18,7 @@ export function ContextMenu({
<ul
data-testid={testId}
ref={ref}
className={cn("bg-tertiary rounded-md", className)}
className={cn("bg-tertiary rounded-md w-[140px]", className)}
>
{children}
</ul>

View File

@@ -22,10 +22,11 @@ export function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const { status } = useWsClient();
const { status, pendingMessages } = useWsClient();
const { notify } = useNotification();
const [statusMessage, setStatusMessage] = React.useState<string>("");
const hasPendingMessages = pendingMessages.length > 0;
const updateStatusMessage = () => {
let message = curStatusMessage.message || "";
@@ -71,7 +72,13 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage("Connecting...");
if (hasPendingMessages) {
setStatusMessage(
`Connecting... (${pendingMessages.length} pending message${pendingMessages.length !== 1 ? "s" : ""})`,
);
} else {
setStatusMessage("Connecting...");
}
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
if (notificationStates.includes(curAgentState)) {
@@ -87,7 +94,7 @@ export function AgentStatusBar() {
}
}
}
}, [curAgentState, notify, t]);
}, [curAgentState, status, pendingMessages.length, notify, t]);
return (
<div className="flex flex-col items-center">

View File

@@ -1,11 +1,12 @@
import { useParams } from "react-router";
import React from "react";
import posthog from "posthog-js";
import { AgentControlBar } from "./agent-control-bar";
import { AgentStatusBar } from "./agent-status-bar";
import { SecurityLock } from "./security-lock";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
import { useAutoTitle } from "#/hooks/use-auto-title";
import { DownloadModal } from "#/components/shared/download-modal";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@@ -17,7 +18,13 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useUserConversation(
params.conversationId ?? null,
);
useAutoTitle();
const [downloading, setDownloading] = React.useState(false);
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
setDownloading(true);
};
return (
<div className="flex items-center justify-between">
@@ -32,12 +39,17 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
<ConversationCard
variant="compact"
showOptions
onDownloadWorkspace={handleDownloadWorkspace}
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}
status={conversation?.status}
conversationId={conversation?.conversation_id}
/>
<DownloadModal
initialPath=""
onClose={() => setDownloading(false)}
isOpen={downloading}
/>
</div>
);

View File

@@ -7,8 +7,7 @@ interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@@ -16,8 +15,7 @@ export function ConversationCardContextMenu({
onClose,
onDelete,
onEdit,
onDisplayCost,
onDownloadViaVSCode,
onDownload,
position = "bottom",
}: ConversationCardContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -27,7 +25,7 @@ export function ConversationCardContextMenu({
ref={ref}
testId="context-menu"
className={cn(
"right-0 absolute mt-3",
"right-0 absolute",
position === "top" && "bottom-full",
position === "bottom" && "top-full",
)}
@@ -42,20 +40,9 @@ export function ConversationCardContextMenu({
Edit Title
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
Download via VS Code
</ContextMenuListItem>
)}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
Display Cost
{onDownload && (
<ContextMenuListItem testId="download-button" onClick={onDownload}>
Download Workspace
</ContextMenuListItem>
)}
</ContextMenu>

View File

@@ -1,6 +1,4 @@
import React from "react";
import { useSelector } from "react-redux";
import posthog from "posthog-js";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import {
@@ -10,50 +8,36 @@ import {
import { EllipsisButton } from "./ellipsis-button";
import { ConversationCardContextMenu } from "./conversation-card-context-menu";
import { cn } from "#/utils/utils";
import { BaseModal } from "../../shared/modals/base-modal/base-modal";
import { RootState } from "#/store";
interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onChangeTitle?: (title: string) => void;
showOptions?: boolean;
onDownloadWorkspace?: () => void;
isActive?: boolean;
title: string;
selectedRepository: string | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
status?: ProjectStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
showOptions,
onDownloadWorkspace,
isActive,
title,
selectedRepository,
// lastUpdatedAt is kept in props for backward compatibility
// eslint-disable-next-line @typescript-eslint/no-unused-vars
lastUpdatedAt,
createdAt,
status = "STOPPED",
variant = "default",
conversationId,
}: ConversationCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
// Subscribe to metrics data from Redux store
const metrics = useSelector((state: RootState) => state.metrics);
const handleBlur = () => {
if (inputRef.current?.value) {
const trimmed = inputRef.current.value.trim();
@@ -94,36 +78,9 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleDownloadViaVSCode = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
const handleDownload = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
posthog.capture("download_via_vscode_button_clicked");
// Fetch the VS Code URL from the API
if (conversationId) {
try {
const response = await fetch(
`/api/conversations/${conversationId}/vscode-url`,
);
const data = await response.json();
if (data.vscode_url) {
window.open(data.vscode_url, "_blank");
}
// VS Code URL not available
} catch (error) {
// Failed to fetch VS Code URL
}
}
setContextMenuVisible(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
setMetricsModalVisible(true);
onDownloadWorkspace?.();
};
React.useEffect(() => {
@@ -132,133 +89,81 @@ export function ConversationCard({
}
}, [titleMode]);
const hasContextMenu = !!(onDelete || onChangeTitle || showOptions);
const timeBetweenUpdateAndCreation = createdAt
? new Date(lastUpdatedAt).getTime() - new Date(createdAt).getTime()
: 0;
const showUpdateTime =
createdAt &&
timeBetweenUpdateAndCreation > MAX_TIME_BETWEEN_CREATION_AND_UPDATE;
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
return (
<>
<div
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{isActive && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
)}
{titleMode === "edit" && (
<input
ref={inputRef}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
)}
{titleMode === "view" && (
<p
data-testid="conversation-card-title"
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
title={title}
>
{title}
</p>
)}
</div>
<div className="flex items-center">
<ConversationStateIndicator status={status} />
{hasContextMenu && (
<div className="pl-2">
<EllipsisButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
</div>
)}
<div className="relative">
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
? handleDownloadViaVSCode
: undefined
}
onDisplayCost={showOptions ? handleDisplayCost : undefined}
position={variant === "compact" ? "top" : "bottom"}
/>
)}
</div>
</div>
<div
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"h-auto w-fit rounded-xl border border-[#525252]",
)}
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 flex-1 min-w-0 overflow-hidden mr-2">
{isActive && (
<span className="w-2 h-2 bg-blue-500 rounded-full flex-shrink-0" />
)}
{titleMode === "edit" && (
<input
ref={inputRef}
data-testid="conversation-card-title"
onClick={handleInputClick}
onBlur={handleBlur}
onKeyUp={handleKeyUp}
type="text"
defaultValue={title}
className="text-sm leading-6 font-semibold bg-transparent w-full"
/>
)}
{titleMode === "view" && (
<p
data-testid="conversation-card-title"
className="text-sm leading-6 font-semibold bg-transparent truncate overflow-hidden"
title={title}
>
{title}
</p>
)}
</div>
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
<div className="flex items-center gap-2 relative">
<ConversationStateIndicator status={status} />
{hasContextMenu && (
<EllipsisButton
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
}}
/>
)}
>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownload={onDownloadWorkspace && handleDownload}
position={variant === "compact" ? "top" : "bottom"}
/>
)}
<p className="text-xs text-neutral-400">
<span>Created </span>
<time>
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))} ago
</time>
{showUpdateTime && (
<>
<span>, updated </span>
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</>
)}
</p>
</div>
</div>
<BaseModal
isOpen={metricsModalVisible}
onOpenChange={setMetricsModalVisible}
title="Metrics Information"
testID="metrics-modal"
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
)}
>
<div className="space-y-2">
{metrics?.cost !== null && (
<p>Total Cost: ${metrics.cost.toFixed(4)}</p>
)}
{metrics?.usage !== null && (
<>
<p>Tokens Used:</p>
<ul className="list-inside space-y-1 ml-2">
<li>- Input: {metrics.usage.prompt_tokens}</li>
<li>- Output: {metrics.usage.completion_tokens}</li>
<li>- Total: {metrics.usage.total_tokens}</li>
</ul>
</>
)}
{!metrics?.cost && !metrics?.usage && (
<p className="text-neutral-400">No metrics data available</p>
)}
</div>
</BaseModal>
</>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
)}
<p className="text-xs text-neutral-400">
<time>{formatTimeDelta(new Date(lastUpdatedAt))} ago</time>
</p>
</div>
</div>
);
}

View File

@@ -108,9 +108,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
title={project.title}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
createdAt={project.created_at}
status={project.status}
conversationId={project.conversation_id}
/>
)}
</NavLink>

View File

@@ -5,12 +5,23 @@ interface ConversationRepoLinkProps {
export function ConversationRepoLink({
selectedRepository,
}: ConversationRepoLinkProps) {
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
window.open(
`https://github.com/${selectedRepository}`,
"_blank",
"noopener,noreferrer",
);
};
return (
<span
<button
type="button"
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
onClick={handleClick}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{selectedRepository}
</span>
</button>
);
}

View File

@@ -1,30 +0,0 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { setInitialPrompt } from "#/state/initial-query-slice";
const INITIAL_PROMPT = "";
export function CodeNotInGitHubLink() {
const dispatch = useDispatch();
const { mutate: createConversation } = useCreateConversation();
const handleStartFromScratch = () => {
// Set the initial prompt and create a new conversation
dispatch(setInitialPrompt(INITIAL_PROMPT));
createConversation({ q: INITIAL_PROMPT });
};
return (
<div className="text-xs text-neutral-400">
Code not in GitHub?{" "}
<span
onClick={handleStartFromScratch}
className="underline cursor-pointer"
>
Start from scratch
</span>{" "}
and use the VS Code link to upload and download your code.
</div>
);
}

View File

@@ -63,8 +63,8 @@ export function PaymentForm() {
name="top-up-input"
onChange={handleTopUpInputChange}
type="text"
label="Add funds"
placeholder="Specify an amount (USD) to add to your account"
label="Top-up amount"
placeholder="Specify an amount to top up your credits"
className="w-[680px]"
/>

View File

@@ -10,6 +10,7 @@ import { DocsButton } from "#/components/shared/buttons/docs-button";
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useCurrentSettings } from "#/context/settings-context";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { useEndSession } from "#/hooks/use-end-session";
@@ -21,7 +22,7 @@ import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { HIDE_LLM_SETTINGS } from "#/utils/feature-flags";
export function Sidebar() {
const location = useLocation();
@@ -30,13 +31,12 @@ export function Sidebar() {
const user = useGitHubUser();
const { data: config } = useConfig();
const {
data: settings,
error: settingsError,
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { mutateAsync: logout } = useLogout();
const { mutate: saveUserSettings } = useSaveSettings();
const { settings, saveUserSettings } = useCurrentSettings();
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
@@ -44,11 +44,10 @@ export function Sidebar() {
React.useState(false);
// TODO: Remove HIDE_LLM_SETTINGS check once released
const shouldHideLlmSettings =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && config?.APP_MODE === "saas";
const isSaas = HIDE_LLM_SETTINGS() && config?.APP_MODE === "saas";
React.useEffect(() => {
if (shouldHideLlmSettings) return;
if (isSaas) return;
if (location.pathname === "/settings") {
setSettingsModalIsOpen(false);
@@ -79,7 +78,7 @@ export function Sidebar() {
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else saveUserSettings({ unset_github_token: true });
else await saveUserSettings({ unset_github_token: true });
posthog.reset();
};
@@ -105,10 +104,10 @@ export function Sidebar() {
)}
/>
</TooltipButton>
<DocsButton />
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton />
<NavLink
to="/settings"
className={({ isActive }) =>

View File

@@ -0,0 +1,33 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SuggestionBox } from "./suggestion-box";
interface ImportProjectSuggestionBoxProps {
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
}
export function ImportProjectSuggestionBox({
onChange,
}: ImportProjectSuggestionBoxProps) {
const { t } = useTranslation();
return (
<SuggestionBox
title={t(I18nKey.LANDING$IMPORT_PROJECT)}
content={
<label htmlFor="import-project" className="w-full flex justify-center">
<span className="border-2 border-dashed border-neutral-600 rounded px-2 py-1 cursor-pointer">
{t(I18nKey.LANDING$UPLOAD_ZIP)}
</span>
<input
hidden
type="file"
accept="application/zip"
id="import-project"
multiple={false}
onChange={onChange}
/>
</label>
}
/>
);
}

View File

@@ -23,7 +23,7 @@ export function NavTab({ to, label, icon, isBeta }: NavTabProps) {
>
{({ isActive }) => (
<>
<div className={cn(isActive && "text-logo")}>{icon}</div>
<div className={cn(isActive && "text-primary")}>{icon}</div>
{label}
{isBeta && <BetaBadge />}
</>

View File

@@ -0,0 +1,23 @@
import ChevronDoubleRight from "#/icons/chevron-double-right.svg?react";
import { cn } from "#/utils/utils";
interface ContinueButtonProps {
onClick: () => void;
}
export function ContinueButton({ onClick }: ContinueButtonProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"button-base px-2 py-1",
"text-[11px] leading-4 tracking-[0.01em] font-[500]",
"flex items-center gap-2",
)}
>
<ChevronDoubleRight width={12} height={12} />
Continue
</button>
);
}

View File

@@ -0,0 +1,33 @@
import { useDownloadProgress } from "#/hooks/use-download-progress";
import { DownloadProgress } from "./download-progress";
interface DownloadModalProps {
initialPath: string;
onClose: () => void;
isOpen: boolean;
}
function ActiveDownload({
initialPath,
onClose,
}: {
initialPath: string;
onClose: () => void;
}) {
const { progress, cancelDownload } = useDownloadProgress(
initialPath,
onClose,
);
return <DownloadProgress progress={progress} onCancel={cancelDownload} />;
}
export function DownloadModal({
initialPath,
onClose,
isOpen,
}: DownloadModalProps) {
if (!isOpen) return null;
return <ActiveDownload initialPath={initialPath} onClose={onClose} />;
}

View File

@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export interface DownloadProgressState {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadProgressProps {
progress: DownloadProgressState;
onCancel: () => void;
}
export function DownloadProgress({
progress,
onCancel,
}: DownloadProgressProps) {
const { t } = useTranslation();
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<div className="bg-[#1C1C1C] rounded-lg p-6 max-w-md w-full mx-4 border border-[#525252]">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2 text-white">
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$PREPARING)
: t(I18nKey.DOWNLOAD$DOWNLOADING)}
</h3>
<p className="text-sm text-gray-400 truncate">
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$FOUND_FILES, { count: progress.filesTotal })
: progress.currentFile}
</p>
</div>
<div className="mb-4">
<div className="h-2 bg-[#2C2C2C] rounded-full overflow-hidden">
{progress.isDiscoveringFiles ? (
<div
className="h-full bg-blue-500 animate-pulse"
style={{ width: "100%" }}
/>
) : (
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%`,
}}
/>
)}
</div>
</div>
<div className="flex justify-between text-sm text-gray-400">
<span>
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$SCANNING)
: t(I18nKey.DOWNLOAD$FILES_PROGRESS, {
downloaded: progress.filesDownloaded,
total: progress.filesTotal,
})}
</span>
{!progress.isDiscoveringFiles && (
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
)}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
>
{t(I18nKey.DOWNLOAD$CANCEL)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -9,12 +9,12 @@ import { extractSettings } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { useCurrentSettings } from "#/context/settings-context";
import { Settings } from "#/types/settings";
import { BrandButton } from "#/components/features/settings/brand-button";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { HelpLink } from "#/components/features/settings/help-link";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
interface SettingsFormProps {
settings: Settings;
@@ -23,7 +23,7 @@ interface SettingsFormProps {
}
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const { mutate: saveUserSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const endSession = useEndSession();
const location = useLocation();
@@ -96,7 +96,6 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
label="API Key"
type="password"
className="w-[680px]"
placeholder={isLLMKeySet ? "<hidden>" : ""}
startContent={isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />}
/>

View File

@@ -56,7 +56,7 @@ export function TaskForm({ ref }: TaskFormProps) {
};
return (
<div className="flex flex-col gap-1 w-full">
<div className="flex flex-col gap-2 w-full">
<form
ref={ref}
onSubmit={handleSubmit}

View File

@@ -0,0 +1,74 @@
import React from "react";
import { MutateOptions } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { PostSettings, Settings } from "#/types/settings";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
type SaveUserSettingsConfig = {
onSuccess: MutateOptions<void, Error, Partial<PostSettings>>["onSuccess"];
};
interface SettingsContextType {
saveUserSettings: (
newSettings: Partial<PostSettings>,
config?: SaveUserSettingsConfig,
) => Promise<void>;
settings: Settings | undefined;
}
const SettingsContext = React.createContext<SettingsContextType | undefined>(
undefined,
);
interface SettingsProviderProps {
children: React.ReactNode;
}
export function SettingsProvider({ children }: SettingsProviderProps) {
const { data: userSettings } = useSettings();
const { mutateAsync: saveSettings } = useSaveSettings();
const saveUserSettings = async (
newSettings: Partial<PostSettings>,
config?: SaveUserSettingsConfig,
) => {
const updatedSettings: Partial<PostSettings> = {
...userSettings,
...newSettings,
};
if (updatedSettings.LLM_API_KEY === "**********") {
delete updatedSettings.LLM_API_KEY;
}
await saveSettings(updatedSettings, {
onSuccess: config?.onSuccess,
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage);
},
});
};
const value = React.useMemo(
() => ({
saveUserSettings,
settings: userSettings,
}),
[saveUserSettings, userSettings],
);
return <SettingsContext value={value}>{children}</SettingsContext>;
}
export function useCurrentSettings() {
const context = React.useContext(SettingsContext);
if (context === undefined) {
throw new Error(
"useCurrentSettings must be used within a SettingsProvider",
);
}
return context;
}

View File

@@ -49,12 +49,14 @@ interface UseWsClient {
isLoadingMessages: boolean;
events: Record<string, unknown>[];
send: (event: Record<string, unknown>) => void;
pendingMessages: Record<string, unknown>[];
}
const WsClientContext = React.createContext<UseWsClient>({
status: WsClientProviderStatus.DISCONNECTED,
isLoadingMessages: true,
events: [],
pendingMessages: [],
send: () => {
throw new Error("not connected");
},
@@ -109,26 +111,43 @@ export function WsClientProvider({
WsClientProviderStatus.DISCONNECTED,
);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const [pendingMessages, setPendingMessages] = React.useState<
Record<string, unknown>[]
>([]);
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const messageRateHandler = useRate({ threshold: 250 });
// Private function to queue messages for later sending
const queueMessage = (event: Record<string, unknown>) => {
EventLogger.info(`Queueing message: ${JSON.stringify(event)}`);
setPendingMessages((prev) => [...prev, event]);
};
function send(event: Record<string, unknown>) {
if (!sioRef.current) {
EventLogger.error("WebSocket is not connected.");
EventLogger.info("WebSocket is not connected, queueing message");
queueMessage(event);
return;
}
sioRef.current.emit("oh_user_action", event);
// Send the message to the backend
EventLogger.info(`Sending message: ${JSON.stringify(event)}`);
sioRef.current.emit("oh_action", event);
}
function handleConnect() {
setStatus(WsClientProviderStatus.CONNECTED);
EventLogger.info(
`WebSocket connected. Pending messages: ${pendingMessages.length}`,
);
}
function handleMessage(event: Record<string, unknown>) {
if (isOpenHandsEvent(event) && isMessageAction(event)) {
messageRateHandler.record(new Date().getTime());
}
setEvents((prevEvents) => [...prevEvents, event]);
if (!Number.isNaN(parseInt(event.id as string, 10))) {
lastEventRef.current = event;
@@ -145,14 +164,39 @@ export function WsClientProvider({
}
sio.io.opts.query = sio.io.opts.query || {};
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
EventLogger.info(
`WebSocket disconnected. Latest event ID: ${lastEventRef.current?.id}`,
);
updateStatusWhenErrorMessagePresent(data);
}
function handleError(data: unknown) {
setStatus(WsClientProviderStatus.DISCONNECTED);
EventLogger.error(`WebSocket connection error: ${JSON.stringify(data)}`);
updateStatusWhenErrorMessagePresent(data);
}
// Process any pending messages when the WebSocket connects
React.useEffect(() => {
if (
status === WsClientProviderStatus.CONNECTED &&
pendingMessages.length > 0 &&
sioRef.current
) {
// We're connected and have pending messages
EventLogger.info(
`Connected! Sending ${pendingMessages.length} queued messages`,
);
pendingMessages.forEach((event) => {
sioRef.current?.emit("oh_action", event);
});
setPendingMessages([]);
EventLogger.info("All queued messages sent, queue cleared");
}
}, [status, pendingMessages.length]);
React.useEffect(() => {
lastEventRef.current = null;
}, [conversationId]);
@@ -210,9 +254,10 @@ export function WsClientProvider({
status,
isLoadingMessages: messageRateHandler.isUnderThreshold,
events,
pendingMessages,
send,
}),
[status, messageRateHandler.isUnderThreshold, events],
[status, messageRateHandler.isUnderThreshold, events, pendingMessages],
);
return <WsClientContext value={value}>{children}</WsClientContext>;

View File

@@ -16,6 +16,7 @@ import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import { SettingsProvider } from "./context/settings-context";
function PosthogInit() {
const { data: config } = useConfig();
@@ -55,8 +56,10 @@ prepareApp().then(() =>
<Provider store={store}>
<AuthProvider>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<SettingsProvider>
<HydratedRouter />
<PosthogInit />
</SettingsProvider>
</QueryClientProvider>
</AuthProvider>
</Provider>

View File

@@ -11,12 +11,21 @@ export const useCreateConversation = () => {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const { selectedRepository, files } = useSelector(
const { selectedRepository, files, importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
return useMutation({
mutationFn: async (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
files.length === 0 &&
!importedProjectZip
) {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(

View File

@@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const resetLlmApiKey = settings.LLM_API_KEY === "";
@@ -30,25 +29,9 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
export const useSaveSettings = () => {
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
return useMutation({
mutationFn: async (settings: Partial<PostSettings>) => {
const newSettings = { ...currentSettings, ...settings };
// Temp hack for reset logic
if (
settings.LLM_API_KEY === undefined &&
settings.LLM_BASE_URL === undefined &&
settings.LLM_MODEL === undefined
) {
delete newSettings.LLM_API_KEY;
delete newSettings.LLM_BASE_URL;
delete newSettings.LLM_MODEL;
}
await saveSettingsMutationFn(newSettings);
},
mutationFn: saveSettingsMutationFn,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
},

View File

@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
export const useBalance = () => {
const { data: config } = useConfig();
@@ -8,7 +9,6 @@ export const useBalance = () => {
return useQuery({
queryKey: ["user", "balance"],
queryFn: OpenHands.getBalance,
enabled:
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
enabled: config?.APP_MODE === "saas" && BILLING_SETTINGS(),
});
};

View File

@@ -5,13 +5,13 @@ import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useLogout } from "../mutation/use-logout";
import { useSaveSettings } from "../mutation/use-save-settings";
import { useCurrentSettings } from "#/context/settings-context";
export const useGitHubUser = () => {
const { githubTokenIsSet } = useAuth();
const { setGitHubTokenIsSet } = useAuth();
const { mutateAsync: logout } = useLogout();
const { mutate: saveUserSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const { data: config } = useConfig();
const user = useQuery({
@@ -38,7 +38,7 @@ export const useGitHubUser = () => {
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else {
saveUserSettings({ unset_github_token: true });
await saveUserSettings({ unset_github_token: true });
setGitHubTokenIsSet(false);
}
posthog.reset();

View File

@@ -44,13 +44,13 @@ export const useSettings = () => {
});
React.useEffect(() => {
if (query.isFetched && query.data?.LLM_API_KEY) {
if (query.data?.LLM_API_KEY) {
posthog.capture("user_activated");
}
}, [query.data?.LLM_API_KEY, query.isFetched]);
}, [query.data?.LLM_API_KEY]);
React.useEffect(() => {
if (query.isFetched) setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
setGitHubTokenIsSet(!!query.data?.GITHUB_TOKEN_IS_SET);
}, [query.data?.GITHUB_TOKEN_IS_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the

View File

@@ -12,7 +12,9 @@ export const useVSCodeUrl = (config: { enabled: boolean }) => {
return OpenHands.getVSCodeUrl(conversationId);
},
enabled: !!conversationId && config.enabled,
refetchOnMount: true,
refetchOnMount: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
return data;

View File

@@ -1,15 +1,15 @@
import { useCurrentSettings } from "#/context/settings-context";
import { useLogout } from "./mutation/use-logout";
import { useSaveSettings } from "./mutation/use-save-settings";
import { useConfig } from "./query/use-config";
export const useAppLogout = () => {
const { data: config } = useConfig();
const { mutateAsync: logout } = useLogout();
const { mutate: saveUserSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
const handleLogout = async () => {
if (config?.APP_MODE === "saas") await logout();
else saveUserSettings({ unset_github_token: true });
else await saveUserSettings({ unset_github_token: true });
};
return { handleLogout };

View File

@@ -1,82 +0,0 @@
import { useEffect } from "react";
import { useParams } from "react-router";
import { useSelector, useDispatch } from "react-redux";
import { useQueryClient } from "@tanstack/react-query";
import { useUpdateConversation } from "./mutation/use-update-conversation";
import { RootState } from "#/store";
import OpenHands from "#/api/open-hands";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
const defaultTitlePattern = /^Conversation [a-f0-9]+$/;
/**
* Hook that monitors for the first agent message and triggers title generation.
* This approach is more robust as it ensures the user message has been processed
* by the backend and the agent has responded before generating the title.
*/
export function useAutoTitle() {
const { conversationId } = useParams<{ conversationId: string }>();
const { data: conversation } = useUserConversation(conversationId ?? null);
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { mutate: updateConversation } = useUpdateConversation();
const messages = useSelector((state: RootState) => state.chat.messages);
useEffect(() => {
if (
!conversation ||
!conversationId ||
!messages ||
messages.length === 0
) {
return;
}
const hasAgentMessage = messages.some(
(message) => message.sender === "assistant",
);
const hasUserMessage = messages.some(
(message) => message.sender === "user",
);
if (!hasAgentMessage || !hasUserMessage) {
return;
}
if (conversation.title && !defaultTitlePattern.test(conversation.title)) {
return;
}
updateConversation(
{
id: conversationId,
conversation: { title: "" },
},
{
onSuccess: async () => {
try {
const updatedConversation =
await OpenHands.getConversation(conversationId);
queryClient.setQueryData(
["user", "conversation", conversationId],
updatedConversation,
);
} catch (error) {
queryClient.invalidateQueries({
queryKey: ["user", "conversation", conversationId],
});
}
},
},
);
}, [
messages,
conversationId,
conversation,
updateConversation,
queryClient,
dispatch,
]);
}

View File

@@ -1,30 +0,0 @@
import { useParams } from "react-router";
import { useEffect, useRef } from "react";
import { useUserConversation } from "./query/use-user-conversation";
/**
* Hook that updates the document title based on the current conversation.
* This ensures that any changes to the conversation title are reflected in the document title.
*
* @param suffix Optional suffix to append to the title (default: "OpenHands")
*/
export function useDocumentTitleFromState(suffix = "OpenHands") {
const params = useParams();
const { data: conversation } = useUserConversation(
params.conversationId ?? null,
);
const lastValidTitleRef = useRef<string | null>(null);
useEffect(() => {
if (conversation?.title) {
lastValidTitleRef.current = conversation.title;
document.title = `${conversation.title} - ${suffix}`;
} else {
document.title = suffix;
}
return () => {
document.title = suffix;
};
}, [conversation, suffix]);
}

View File

@@ -0,0 +1,80 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";
import { DownloadProgressState } from "#/components/shared/download-progress";
import { useConversation } from "#/context/conversation-context";
export const INITIAL_PROGRESS: DownloadProgressState = {
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
export function useDownloadProgress(
initialPath: string | undefined,
onClose: () => void,
) {
const [isStarted, setIsStarted] = useState(false);
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>(null);
const { conversationId } = useConversation();
// Create AbortController on mount
useEffect(() => {
const controller = new AbortController();
abortController.current = controller;
// Initialize progress ref with initial state
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = null;
};
}, []); // Empty deps array - only run on mount/unmount
// Start download when isStarted becomes true
useEffect(() => {
if (!isStarted) {
setIsStarted(true);
return;
}
if (!abortController.current) return;
// Start download
const download = async () => {
try {
await downloadFiles(conversationId, initialPath, {
onProgress: (p) => {
// Update both the ref and state
progressRef.current = { ...p };
setProgress((prev: DownloadProgressState) => ({ ...prev, ...p }));
},
signal: abortController.current!.signal,
});
onClose();
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
onClose();
} else {
throw error;
}
}
};
download();
}, [initialPath, onClose, isStarted]);
// No longer need startDownload as it's handled in useEffect
const cancelDownload = useCallback(() => {
abortController.current?.abort();
}, []);
return {
progress,
cancelDownload,
};
}

View File

@@ -1,9 +1,9 @@
import React from "react";
import { useCurrentSettings } from "#/context/settings-context";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSaveSettings } from "./mutation/use-save-settings";
export const useMigrateUserConsent = () => {
const { mutate: saveUserSettings } = useSaveSettings();
const { saveUserSettings } = useCurrentSettings();
/**
* Migrate user consent to the settings store on the server.

View File

@@ -1,9 +1,9 @@
import { useCallback, useRef } from "react";
import notificationSound from "../assets/notification.mp3";
import { useSettings } from "./query/use-settings";
import { useCurrentSettings } from "../context/settings-context";
export const useNotification = () => {
const { data: settings } = useSettings();
const { settings } = useCurrentSettings();
const audioRef = useRef<HTMLAudioElement | undefined>(undefined);
// Initialize audio only in browser environment

View File

@@ -222,7 +222,6 @@ export enum I18nKey {
STATUS$STARTING_CONTAINER = "STATUS$STARTING_CONTAINER",
STATUS$PREPARING_CONTAINER = "STATUS$PREPARING_CONTAINER",
STATUS$CONTAINER_STARTED = "STATUS$CONTAINER_STARTED",
STATUS$SETTING_UP_WORKSPACE = "STATUS$SETTING_UP_WORKSPACE",
ACCOUNT_SETTINGS_MODAL$DISCONNECT = "ACCOUNT_SETTINGS_MODAL$DISCONNECT",
ACCOUNT_SETTINGS_MODAL$SAVE = "ACCOUNT_SETTINGS_MODAL$SAVE",
ACCOUNT_SETTINGS_MODAL$CLOSE = "ACCOUNT_SETTINGS_MODAL$CLOSE",
@@ -281,7 +280,6 @@ export enum I18nKey {
ACTION_MESSAGE$EDIT = "ACTION_MESSAGE$EDIT",
ACTION_MESSAGE$WRITE = "ACTION_MESSAGE$WRITE",
ACTION_MESSAGE$BROWSE = "ACTION_MESSAGE$BROWSE",
ACTION_MESSAGE$BROWSE_INTERACTIVE = "ACTION_MESSAGE$BROWSE_INTERACTIVE",
ACTION_MESSAGE$THINK = "ACTION_MESSAGE$THINK",
OBSERVATION_MESSAGE$RUN = "OBSERVATION_MESSAGE$RUN",
OBSERVATION_MESSAGE$RUN_IPYTHON = "OBSERVATION_MESSAGE$RUN_IPYTHON",

View File

@@ -3308,21 +3308,6 @@
"tr": "Konteyner başlatıldı.",
"ja": "コンテナが開始されました"
},
"STATUS$SETTING_UP_WORKSPACE": {
"en": "Setting up workspace...",
"zh-CN": "正在设置工作区...",
"zh-TW": "正在設置工作區...",
"de": "Arbeitsbereich wird eingerichtet...",
"ko-KR": "작업 공간을 설정하는 중...",
"no": "Setter opp arbeidsområde...",
"it": "Configurazione dell'area di lavoro...",
"pt": "Configurando espaço de trabalho...",
"es": "Configurando espacio de trabajo...",
"ar": "جاري إعداد مساحة العمل...",
"fr": "Configuration de l'espace de travail...",
"tr": "Çalışma alanı ayarlanıyor...",
"ja": "ワークスペースを設定中..."
},
"ACCOUNT_SETTINGS_MODAL$DISCONNECT": {
"en": "Disconnect",
"es": "Desconectar",
@@ -4193,21 +4178,6 @@
"es": "Navegando en la web",
"tr": "Web'de geziniyor"
},
"ACTION_MESSAGE$BROWSE_INTERACTIVE": {
"en": "Interactive browsing in progress...",
"zh-CN": "交互式浏览进行中...",
"zh-TW": "互動式瀏覽進行中...",
"ko-KR": "인터랙티브 브라우징 진행 중...",
"ja": "インタラクティブブラウジング進行中...",
"no": "Interaktiv surfing pågår...",
"ar": "التصفح التفاعلي قيد التقدم...",
"de": "Interaktives Browsen läuft...",
"fr": "Navigation interactive en cours...",
"it": "Navigazione interattiva in corso...",
"pt": "Navegação interativa em andamento...",
"es": "Navegación interactiva en progreso...",
"tr": "Etkileşimli tarama devam ediyor..."
},
"ACTION_MESSAGE$THINK": {
"en": "Thinking",
"zh-CN": "思考",

View File

@@ -128,6 +128,7 @@ const openHandsHandlers = [
const url = new URL(request.url);
const file = url.searchParams.get("file")?.toString();
if (file) {
return HttpResponse.json({ code: `Content of ${file}` });
}
@@ -180,10 +181,6 @@ export const handlers = [
GITHUB_CLIENT_ID: "fake-github-client-id",
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
STRIPE_PUBLISHABLE_KEY: "",
FEATURE_FLAGS: {
ENABLE_BILLING: mockSaas,
HIDE_LLM_SETTINGS: mockSaas,
},
};
return HttpResponse.json(config);

View File

@@ -35,7 +35,7 @@ export const handlers: WebSocketHandler[] = [
);
}
io.client.on("oh_user_action", async (_, data) => {
io.client.on("oh_action", async (_, data) => {
if (isInitConfig(data)) {
io.client.emit(
"oh_event",

View File

@@ -1,13 +1,18 @@
import React from "react";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { convertZipToBase64 } from "#/utils/convert-zip-to-base64";
import { useGitHubUser } from "#/hooks/query/use-github-user";
import { useGitHubAuthUrl } from "#/hooks/use-github-auth-url";
import { useConfig } from "#/hooks/query/use-config";
import { ImportProjectSuggestionBox } from "../../components/features/suggestions/import-project-suggestion-box";
import { GitHubRepositoriesSuggestionBox } from "#/components/features/github/github-repositories-suggestion-box";
import { CodeNotInGitHubLink } from "#/components/features/github/code-not-in-github-link";
import { HeroHeading } from "#/components/shared/hero-heading";
import { TaskForm } from "#/components/shared/task-form";
function Home() {
const dispatch = useDispatch();
const formRef = React.useRef<HTMLFormElement>(null);
const { data: config } = useConfig();
@@ -24,20 +29,29 @@ function Home() {
className="bg-base-secondary h-full rounded-xl flex flex-col items-center justify-center relative overflow-y-auto px-2"
>
<HeroHeading />
<div className="flex flex-col gap-1 w-full mt-8 md:w-[600px] items-center">
<div className="flex flex-col gap-8 w-full md:w-[600px] items-center">
<div className="flex flex-col gap-2 w-full">
<TaskForm ref={formRef} />
</div>
<div className="flex gap-4 w-full flex-col md:flex-row mt-8">
<div className="flex gap-4 w-full flex-col md:flex-row">
<GitHubRepositoriesSuggestionBox
handleSubmit={() => formRef.current?.requestSubmit()}
gitHubAuthUrl={gitHubAuthUrl}
user={user || null}
/>
</div>
<div className="w-full flex justify-start mt-2 ml-2">
<CodeNotInGitHubLink />
<ImportProjectSuggestionBox
onChange={async (event) => {
if (event.target.files) {
const zip = event.target.files[0];
dispatch(setImportedProjectZip(await convertZipToBase64(zip)));
posthog.capture("zip_file_uploaded");
formRef.current?.requestSubmit();
} else {
// TODO: handle error
}
}}
/>
</div>
</div>
</div>

View File

@@ -78,18 +78,20 @@ function FileViewer() {
<div className="flex h-full bg-base-secondary relative">
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
<div className="w-full h-full flex flex-col">
{selectedPath && (
<div className="flex w-full items-center justify-between self-end p-2">
<span className="text-sm text-neutral-500">{selectedPath}</span>
</div>
)}
{selectedPath && files[selectedPath] && (
<div className="h-full w-full overflow-auto">
<div className="p-4 flex-1 overflow-auto">
<SyntaxHighlighter
language={getLanguageFromPath(selectedPath)}
style={vscDarkPlus}
customStyle={{
margin: 0,
padding: "10px",
height: "100%",
background: "#171717",
fontSize: "0.875rem",
borderRadius: 0,
}}
>
{files[selectedPath]}

View File

@@ -1,12 +1,44 @@
import { useSelector } from "react-redux";
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { setImportedProjectZip } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { base64ToBlob } from "#/utils/base64-to-blob";
import { useUploadFiles } from "../../../hooks/mutation/use-upload-files";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
export const useHandleRuntimeActive = () => {
const dispatch = useDispatch();
const { mutate: uploadFiles } = useUploadFiles();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
return { runtimeActive };
const { importedProjectZip } = useSelector(
(state: RootState) => state.initialQuery,
);
const handleUploadFiles = (zip: string) => {
const blob = base64ToBlob(zip);
const file = new File([blob], "imported-project.zip", {
type: blob.type,
});
uploadFiles(
{ files: [file] },
{
onError: () => {
displayErrorToast("Failed to upload project files.");
},
},
);
dispatch(setImportedProjectZip(null));
};
React.useEffect(() => {
if (runtimeActive && importedProjectZip) {
handleUploadFiles(importedProjectZip);
}
}, [runtimeActive, importedProjectZip]);
};

View File

@@ -20,6 +20,7 @@ import { useAuth } from "#/context/auth-context";
import { useMigrateUserConsent } from "#/hooks/use-migrate-user-consent";
import { useBalance } from "#/hooks/query/use-balance";
import { SetupPaymentModal } from "#/components/features/payment/setup-payment-modal";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
export function ErrorBoundary() {
@@ -144,7 +145,7 @@ export default function MainApp() {
/>
)}
{config.data?.FEATURE_FLAGS.ENABLE_BILLING &&
{BILLING_SETTINGS() &&
config.data?.APP_MODE === "saas" &&
settings?.IS_NEW_USER && <SetupPaymentModal />}
</div>

View File

@@ -26,6 +26,7 @@ import {
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { PostSettings } from "#/types/settings";
import { HIDE_LLM_SETTINGS } from "#/utils/feature-flags";
const REMOTE_RUNTIME_OPTIONS = [
{ key: 1, label: "1x (2 core, 8G)" },
@@ -52,8 +53,7 @@ function AccountSettings() {
const isSuccess = isSuccessfulSettings && isSuccessfulResources;
const isSaas = config?.APP_MODE === "saas";
const shouldHandleSpecialSaasCase =
config?.FEATURE_FLAGS.HIDE_LLM_SETTINGS && isSaas;
const shouldHandleSpecialSaasCase = HIDE_LLM_SETTINGS() && isSaas;
const determineWhetherToToggleAdvancedSettings = () => {
if (shouldHandleSpecialSaasCase) return true;
@@ -288,7 +288,7 @@ function AccountSettings() {
startContent={
isLLMKeySet && <KeyStatusIcon isSet={isLLMKeySet} />
}
placeholder={isLLMKeySet ? "<hidden>" : ""}
placeholder={isLLMKeySet ? "**********" : ""}
/>
)}
@@ -407,9 +407,9 @@ function AccountSettings() {
<KeyStatusIcon isSet={!!isGitHubTokenSet} />
)
}
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
placeholder={isGitHubTokenSet ? "**********" : ""}
/>
<p data-testid="github-token-help-anchor" className="text-xs">
<p data-testId="github-token-help-anchor" className="text-xs">
{" "}
Generate a token on{" "}
<b>

View File

@@ -7,11 +7,12 @@ import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { BILLING_SETTINGS } from "#/utils/feature-flags";
export const clientLoader = async () => {
const config = queryClient.getQueryData<GetConfigResponse>(["config"]);
if (config?.APP_MODE !== "saas" || !config.FEATURE_FLAGS.ENABLE_BILLING) {
if (config?.APP_MODE !== "saas" || !BILLING_SETTINGS()) {
return redirect("/settings");
}

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