mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e02e3bb42 | |||
| 675d0d8997 | |||
| d680a2fe82 | |||
| 43dfcf0a34 | |||
| 842d716e49 | |||
| 6c2f020dec | |||
| a072aa099e | |||
| 664f7991f4 | |||
| 5dab465dd0 | |||
| dd3c5dc6af | |||
| 0e4ae562a4 | |||
| 47013f8d58 | |||
| 0c1dd28775 | |||
| e9c1312243 | |||
| 84766aaba1 | |||
| 8ad6e547b8 | |||
| 8a7bd9645f | |||
| 0378aefab8 | |||
| ab5d8391e4 | |||
| a36360311f |
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.4.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3.4.0
|
||||
uses: docker/setup-qemu-action@v3.3.0
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
- name: Login to GHCR
|
||||
@@ -233,7 +233,7 @@ jobs:
|
||||
run: pipx install poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies
|
||||
- name: Run docker runtime tests
|
||||
- name: Run runtime tests
|
||||
run: |
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
echo "SANDBOX_ENV_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
|
||||
|
||||
# Set branch variables
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Comment on issue with start message
|
||||
uses: actions/github-script@v7
|
||||
@@ -232,7 +232,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
@@ -269,7 +268,6 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
@@ -279,7 +277,6 @@ jobs:
|
||||
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--target-branch ${{ env.TARGET_BRANCH }} \
|
||||
--pr-type draft \
|
||||
--reviewer ${{ github.actor }} | tee pr_result.txt && \
|
||||
grep "draft created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
# OpenHands Glossary
|
||||
|
||||
### Agent
|
||||
The core AI entity in OpenHands that can perform software development tasks by interacting with tools, browsing the web, and modifying code.
|
||||
|
||||
#### Agent Controller
|
||||
A component that manages the agent's lifecycle, handles its state, and coordinates interactions between the agent and various tools.
|
||||
|
||||
#### Agent Delegation
|
||||
The ability of an agent to hand off specific tasks to other specialized agents for better task completion.
|
||||
|
||||
#### Agent Hub
|
||||
A central registry of different agent types and their capabilities, allowing for easy agent selection and instantiation.
|
||||
|
||||
#### Agent Skill
|
||||
A specific capability or function that an agent can perform, such as file manipulation, web browsing, or code editing.
|
||||
|
||||
#### Agent State
|
||||
The current context and status of an agent, including its memory, active tools, and ongoing tasks.
|
||||
|
||||
#### CodeAct Agent
|
||||
[A generalist agent in OpenHands](https://arxiv.org/abs/2407.16741) designed to perform tasks by editing and executing code.
|
||||
|
||||
### Browser
|
||||
A system for web-based interactions and tasks.
|
||||
|
||||
#### Browser Gym
|
||||
A testing and evaluation environment for browser-based agent interactions and tasks.
|
||||
|
||||
#### Web Browser Tool
|
||||
A tool that enables agents to interact with web pages and perform web-based tasks.
|
||||
|
||||
### Commands
|
||||
Terminal and execution related functionality.
|
||||
|
||||
#### Bash Session
|
||||
A persistent terminal session that maintains state and history for bash command execution.
|
||||
This uses tmux under the hood.
|
||||
|
||||
### Configuration
|
||||
System-wide settings and options.
|
||||
|
||||
#### Agent Configuration
|
||||
Settings that define an agent's behavior, capabilities, and limitations, including available tools and runtime settings.
|
||||
|
||||
#### Configuration Options
|
||||
Settings that control various aspects of OpenHands behavior, including runtime, security, and agent settings.
|
||||
|
||||
#### LLM Config
|
||||
Configuration settings for language models used by agents, including model selection and parameters.
|
||||
|
||||
#### LLM Draft Config
|
||||
Settings for draft mode operations with language models, typically used for faster, lower-quality responses.
|
||||
|
||||
#### Runtime Configuration
|
||||
Settings that define how the runtime environment should be set up and operated.
|
||||
|
||||
#### Security Options
|
||||
Configuration settings that control security features and restrictions.
|
||||
|
||||
### Conversation
|
||||
A sequence of interactions between a user and an agent, including messages, actions, and their results.
|
||||
|
||||
#### Conversation Info
|
||||
Metadata about a conversation, including its status, participants, and timeline.
|
||||
|
||||
#### Conversation Manager
|
||||
A component that handles the creation, storage, and retrieval of conversations.
|
||||
|
||||
#### Conversation Metadata
|
||||
Additional information about conversations, such as tags, timestamps, and related resources.
|
||||
|
||||
#### Conversation Status
|
||||
The current state of a conversation, including whether it's active, completed, or failed.
|
||||
|
||||
#### Conversation Store
|
||||
A storage system for maintaining conversation history and related data.
|
||||
|
||||
### Events
|
||||
|
||||
#### Event
|
||||
Every Conversation comprises a series of Events. Each Event is either an Action or an Observation.
|
||||
|
||||
#### Event Stream
|
||||
A continuous flow of events that represents the ongoing activities and interactions in the system.
|
||||
|
||||
#### Action
|
||||
A specific operation or command that an agent executes through available tools, such as running a command or editing a file.
|
||||
|
||||
#### Observation
|
||||
The response or result returned by a tool after an agent's action, providing feedback about the action's outcome.
|
||||
|
||||
### Interface
|
||||
Different ways to interact with OpenHands.
|
||||
|
||||
#### CLI Mode
|
||||
A command-line interface mode for interacting with OpenHands agents without a graphical interface.
|
||||
|
||||
#### GUI Mode
|
||||
A graphical user interface mode for interacting with OpenHands agents through a web interface.
|
||||
|
||||
#### Headless Mode
|
||||
A mode of operation where OpenHands runs without a user interface, suitable for automation and scripting.
|
||||
|
||||
### Agent Memory
|
||||
The system that decides which parts of the Event Stream (i.e. the conversation history) should be passed into each LLM prompt.
|
||||
|
||||
#### Memory Store
|
||||
A storage system for maintaining agent memory and context across sessions.
|
||||
|
||||
#### Condenser
|
||||
A component that processes and summarizes conversation history to maintain context while staying within token limits.
|
||||
|
||||
#### Truncation
|
||||
A very simple Condenser strategy. Reduces conversation history or content to stay within token limits.
|
||||
|
||||
### Microagent
|
||||
A specialized prompt that enhances OpenHands with domain-specific knowledge, repository-specific context, and task-specific workflows.
|
||||
|
||||
#### Microagent Registry
|
||||
A central repository of available microagents and their configurations.
|
||||
|
||||
#### Public Microagent
|
||||
A general-purpose microagent available to all OpenHands users, triggered by specific keywords.
|
||||
|
||||
#### Repository Microagent
|
||||
A type of microagent that provides repository-specific context and guidelines, stored in the `.openhands/microagents/` directory.
|
||||
|
||||
### Prompt
|
||||
Components for managing and processing prompts.
|
||||
|
||||
#### Prompt Caching
|
||||
A system for caching and reusing common prompts to improve performance.
|
||||
|
||||
#### Prompt Manager
|
||||
A component that handles the loading, processing, and management of prompts used by agents, including microagents.
|
||||
|
||||
#### Response Parsing
|
||||
The process of interpreting and structuring responses from language models and tools.
|
||||
|
||||
### Runtime
|
||||
The execution environment where agents perform their tasks, which can be local, remote, or containerized.
|
||||
|
||||
#### Action Execution Server
|
||||
A REST API that receives agent actions (e.g. bash commands, python code, browsing actions), executes them in the runtime environment, and returns the results.
|
||||
|
||||
#### Action Execution Client
|
||||
A component that handles the execution of actions in the runtime environment, managing the communication between the agent and the runtime.
|
||||
|
||||
#### Docker Runtime
|
||||
A containerized runtime environment that provides isolation and reproducibility for agent operations.
|
||||
|
||||
#### E2B Runtime
|
||||
A specialized runtime environment built on E2B for secure and isolated code execution.
|
||||
|
||||
#### Local Runtime
|
||||
A runtime environment that executes on the local machine, suitable for development and testing.
|
||||
|
||||
#### Modal Runtime
|
||||
A runtime environment built on Modal for scalable and distributed agent operations.
|
||||
|
||||
#### Remote Runtime
|
||||
A sandboxed environment that executes code and commands remotely, providing isolation and security for agent operations.
|
||||
|
||||
#### Runtime Builder
|
||||
A component that builds a Docker image for the Action Execution Server based on a user-specified base image.
|
||||
|
||||
### Security
|
||||
Security-related components and features.
|
||||
|
||||
#### Security Analyzer
|
||||
A component that checks agent actions for potential security risks.
|
||||
+1
-1
@@ -100,7 +100,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker container image by
|
||||
setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.24-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.23-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -43,17 +43,17 @@ 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.24-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)!
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
cp pyproject.toml poetry.lock openhands
|
||||
poetry build -v
|
||||
|
||||
@@ -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.24-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.23-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -24,6 +24,3 @@ inline-quotes = "single"
|
||||
|
||||
[format]
|
||||
quote-style = "single"
|
||||
|
||||
[lint.flake8-bugbear]
|
||||
extend-immutable-calls = ["Depends", "fastapi.Depends", "fastapi.params.Depends"]
|
||||
|
||||
+1
-1
@@ -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.24-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of openhands-state for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -52,7 +52,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -13,16 +13,16 @@
|
||||
La façon la plus simple d'exécuter OpenHands est avec Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23
|
||||
```
|
||||
|
||||
Vous pouvez également exécuter OpenHands en mode [headless scriptable](https://docs.all-hands.dev/modules/usage/how-to/headless-mode), en tant que [CLI interactive](https://docs.all-hands.dev/modules/usage/how-to/cli-mode), ou en utilisant l'[Action GitHub OpenHands](https://docs.all-hands.dev/modules/usage/how-to/github-action).
|
||||
|
||||
@@ -13,7 +13,7 @@ C'est le Runtime par défaut qui est utilisé lorsque vous démarrez OpenHands.
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -50,7 +50,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
+2
-2
@@ -47,7 +47,7 @@ LLM_API_KEY="sk_test_12345"
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi" --no-auto-continue
|
||||
```
|
||||
|
||||
@@ -11,16 +11,16 @@
|
||||
在 Docker 中运行 OpenHands 是最简单的方式。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23
|
||||
```
|
||||
|
||||
你也可以在可脚本化的[无头模式](https://docs.all-hands.dev/modules/usage/how-to/headless-mode)下运行 OpenHands,作为[交互式 CLI](https://docs.all-hands.dev/modules/usage/how-to/cli-mode),或使用 [OpenHands GitHub Action](https://docs.all-hands.dev/modules/usage/how-to/github-action)。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
@@ -54,13 +54,14 @@ graph TD
|
||||
6. Action Execution: The runtime client receives actions from the backend, executes them in the sandboxed environment, and sends back observations
|
||||
7. Observation Return: The action execution server sends execution results back to the OpenHands backend as observations
|
||||
|
||||
The role of the client:
|
||||
|
||||
The role of the client:
|
||||
- It acts as an intermediary between the OpenHands backend and the sandboxed environment
|
||||
- It executes various types of actions (shell commands, file operations, Python code, etc.) safely within the container
|
||||
- It manages the state of the sandboxed environment, including the current working directory and loaded plugins
|
||||
- It formats and returns observations to the backend, ensuring a consistent interface for processing results
|
||||
|
||||
|
||||
## How OpenHands builds and maintains OH Runtime images
|
||||
|
||||
OpenHands' approach to building and managing runtime images ensures efficiency, consistency, and flexibility in creating and maintaining Docker images for both production and development environments.
|
||||
@@ -77,15 +78,16 @@ Tags may be in one of 2 formats:
|
||||
- **Source Tag**: `oh_v{openhands_version}_{16_digit_lock_hash}_{16_digit_source_hash}`
|
||||
(e.g.: `oh_v0.9.9_1234567890abcdef_1234567890abcdef`)
|
||||
|
||||
|
||||
#### Source Tag - Most Specific
|
||||
|
||||
This is the first 16 digits of the MD5 of the directory hash for the source directory. This gives a hash
|
||||
for only the openhands source
|
||||
|
||||
|
||||
#### Lock Tag
|
||||
|
||||
This hash is built from the first 16 digits of the MD5 of:
|
||||
|
||||
- The name of the base image upon which the image was built (e.g.: `nikolaik/python-nodejs:python3.12-nodejs22`)
|
||||
- The content of the `pyproject.toml` included in the image.
|
||||
- The content of the `poetry.lock` included in the image.
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Cloud GitHub Resolver
|
||||
|
||||
The GitHub Resolver automates code fixes and provides intelligent assistance for your repositories.
|
||||
|
||||
## Setup
|
||||
|
||||
The Cloud Github Resolver is available automatically when you
|
||||
[grant OpenHands Cloud repository access](./openhands-cloud.md#adding-repositories).
|
||||
|
||||
## Usage
|
||||
|
||||
### Issues
|
||||
|
||||
On your repository, label an issue with `openhands`. OpenHands will attempt to fix the issue.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
In order to get OpenHands to work on pull requests, use `@openhands` in top level or single inline comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
@@ -1,31 +0,0 @@
|
||||
# Openhands Cloud
|
||||
|
||||
This document provides information about the hosted version of OpenHands.
|
||||
|
||||
## Getting Started
|
||||
|
||||
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
|
||||
1. After reading and accepting the terms of service, click `Connect to GitHub`.
|
||||
2. Then click `Authorize OpenHands by All Hands AI`.
|
||||
- Openhands requests short-lived tokens (8-hour expiry) with these permissions:
|
||||
- Actions: Read and write
|
||||
- Administration: Read-only
|
||||
- Commit statuses: Read and write
|
||||
- Contents: Read and write
|
||||
- Issues: Read and write
|
||||
- Metadata: Read-only
|
||||
- Pull requests: Read and write
|
||||
- Webhooks: Read and write
|
||||
- Workflows: Read and write
|
||||
|
||||
## Adding Repositories
|
||||
|
||||
You can grant OpenHands specific repository access:
|
||||
1. Under the `Select a GitHub project` dropdown, select `Add more repositories...`.
|
||||
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
|
||||
- Repository access for a user is granted based on:
|
||||
- Granted permission for the repository.
|
||||
- User's GitHub permissions (owner/collaborator).
|
||||
|
||||
You can manage repository access any time by following the above workflow or visiting the Settings page and selecting
|
||||
`Configure GitHub Repositories` under the `GitHub Settings` section.
|
||||
@@ -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.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.cli
|
||||
```
|
||||
|
||||
|
||||
@@ -42,10 +42,9 @@ You can provide custom directions for OpenHands by following the [README for the
|
||||
Github resolver will automatically check for valid [repository secrets](https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions?tool=webui#creating-secrets-for-a-repository) or [repository variables](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/store-information-in-variables#creating-configuration-variables-for-a-repository) to customize its behavior.
|
||||
The customization options you can set are:
|
||||
|
||||
| **Attribute name** | **Type** | **Purpose** | **Example** |
|
||||
| -------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
|
||||
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
|
||||
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
|
||||
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
|
||||
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
|
||||
| **Attribute name** | **Type** | **Purpose** | **Example** |
|
||||
|----------------------------------| -------- |-------------------------------------------------------------------------------------------------------------|------------------------------------------------------|
|
||||
| `LLM_MODEL` | Variable | Set the LLM to use with OpenHands | `LLM_MODEL="anthropic/claude-3-5-sonnet-20241022"` |
|
||||
| `OPENHANDS_MAX_ITER` | Variable | Set max limit for agent iterations | `OPENHANDS_MAX_ITER=10` |
|
||||
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
|
||||
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
|
||||
|
||||
@@ -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.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -54,17 +54,17 @@
|
||||
The easiest way to run OpenHands is in Docker.
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-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.24
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.23
|
||||
```
|
||||
|
||||
You'll find OpenHands running at http://localhost:3000!
|
||||
|
||||
@@ -63,22 +63,22 @@ We have a few guides for running OpenHands with specific model providers:
|
||||
### API retries and rate limits
|
||||
|
||||
LLM providers typically have rate limits, sometimes very low, and may require retries. OpenHands will automatically
|
||||
retry requests if it receives a Rate Limit Error (429 error code).
|
||||
retry requests if it receives a Rate Limit Error (429 error code), API connection error, or other transient errors.
|
||||
|
||||
You can customize these options as you need for the provider you're using. Check their documentation, and set the
|
||||
following environment variables to control the number of retries and the time between retries:
|
||||
|
||||
- `LLM_NUM_RETRIES` (Default of 4 times)
|
||||
- `LLM_RETRY_MIN_WAIT` (Default of 5 seconds)
|
||||
- `LLM_RETRY_MAX_WAIT` (Default of 30 seconds)
|
||||
- `LLM_NUM_RETRIES` (Default of 8)
|
||||
- `LLM_RETRY_MIN_WAIT` (Default of 15 seconds)
|
||||
- `LLM_RETRY_MAX_WAIT` (Default of 120 seconds)
|
||||
- `LLM_RETRY_MULTIPLIER` (Default of 2)
|
||||
|
||||
If you are running OpenHands in development mode, you can also set these options in the `config.toml` file:
|
||||
|
||||
```toml
|
||||
[llm]
|
||||
num_retries = 4
|
||||
retry_min_wait = 5
|
||||
retry_max_wait = 30
|
||||
num_retries = 8
|
||||
retry_min_wait = 15
|
||||
retry_max_wait = 120
|
||||
retry_multiplier = 2
|
||||
```
|
||||
|
||||
@@ -16,7 +16,7 @@ some flags being passed to `docker run` that make this possible:
|
||||
|
||||
```
|
||||
docker run # ...
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.24-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.23-nikolaik \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
# ...
|
||||
```
|
||||
|
||||
+2
-19
@@ -42,7 +42,7 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -69,23 +69,6 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Github Actions',
|
||||
id: 'usage/how-to/github-action',
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Cloud',
|
||||
items: [
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Openhands Cloud',
|
||||
id: 'usage/cloud/openhands-cloud',
|
||||
},
|
||||
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Cloud GitHub Resolver',
|
||||
id: 'usage/cloud/cloud-github-resolver',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -202,7 +185,7 @@ const sidebars: SidebarsConfig = {
|
||||
type: 'doc',
|
||||
label: 'About',
|
||||
id: 'usage/about',
|
||||
},
|
||||
}
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ from openhands.core.config import (
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
|
||||
@@ -64,12 +63,8 @@ def get_config(
|
||||
workspace_mount_path=None,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
if metadata.agent_config:
|
||||
config.set_agent_config(metadata.agent_config, metadata.agent_class)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
return config
|
||||
|
||||
|
||||
@@ -243,10 +238,6 @@ if __name__ == '__main__':
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
agent_config = None
|
||||
if args.agent_config:
|
||||
agent_config = get_agent_config_arg(args.agent_config)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
@@ -265,7 +256,6 @@ if __name__ == '__main__':
|
||||
eval_output_dir=args.eval_output_dir,
|
||||
data_split=args.data_split,
|
||||
details={'gaia-level': args.level},
|
||||
agent_config=agent_config,
|
||||
)
|
||||
|
||||
dataset = load_dataset('gaia-benchmark/GAIA', args.level)
|
||||
|
||||
@@ -9,7 +9,6 @@ AGENT=$3
|
||||
EVAL_LIMIT=$4
|
||||
LEVELS=$5
|
||||
NUM_WORKERS=$6
|
||||
AGENT_CONFIG=$7
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
@@ -50,9 +49,5 @@ if [ -n "$EVAL_LIMIT" ]; then
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
if [ -n "$AGENT_CONFIG" ]; then
|
||||
echo "AGENT_CONFIG: $AGENT_CONFIG"
|
||||
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
@@ -28,7 +27,6 @@ from evaluation.utils.shared import (
|
||||
)
|
||||
from openhands.core.config import (
|
||||
AppConfig,
|
||||
LLMConfig,
|
||||
SandboxConfig,
|
||||
get_parser,
|
||||
)
|
||||
@@ -417,17 +415,13 @@ if __name__ == '__main__':
|
||||
else:
|
||||
# Initialize with a dummy metadata when file doesn't exist
|
||||
metadata = EvalMetadata(
|
||||
agent_class='dummy_agent', # Placeholder agent class
|
||||
agent_class="dummy_agent", # Placeholder agent class
|
||||
llm_config=LLMConfig(model="dummy_model"), # Minimal LLM config
|
||||
max_iterations=1, # Minimal iterations
|
||||
eval_output_dir=os.path.dirname(
|
||||
args.input_file
|
||||
), # Use input file dir as output dir
|
||||
eval_output_dir=os.path.dirname(args.input_file), # Use input file dir as output dir
|
||||
start_time=time.strftime('%Y-%m-%d %H:%M:%S'), # Current time
|
||||
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD'])
|
||||
.decode('utf-8')
|
||||
.strip(), # Current commit
|
||||
dataset=args.dataset, # Dataset name from args
|
||||
git_commit=subprocess.check_output(['git', 'rev-parse', 'HEAD']).decode('utf-8').strip(), # Current commit
|
||||
dataset=args.dataset # Dataset name from args
|
||||
)
|
||||
|
||||
# The evaluation harness constrains the signature of `process_instance_func` but we need to
|
||||
|
||||
@@ -7,7 +7,6 @@ This file tracks the resource requirements of different instances.
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
CUR_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
@@ -267,9 +267,7 @@ def pre_login(
|
||||
obs: BrowserOutputObservation = runtime.run_action(browser_action)
|
||||
logger.debug(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
if save_screenshots:
|
||||
image_data = base64.b64decode(
|
||||
obs.screenshot.replace('data:image/png;base64,', '')
|
||||
)
|
||||
image_data = base64.b64decode(obs.screenshot)
|
||||
with open(os.path.join(directory, f'{image_id}.png'), 'wb') as file:
|
||||
file.write(image_data)
|
||||
image_id += 1
|
||||
|
||||
@@ -18,11 +18,9 @@ from openhands.core.config import (
|
||||
AppConfig,
|
||||
LLMConfig,
|
||||
SandboxConfig,
|
||||
get_agent_config_arg,
|
||||
get_llm_config_arg,
|
||||
get_parser,
|
||||
)
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import CmdRunAction, MessageAction
|
||||
@@ -36,7 +34,6 @@ def get_config(
|
||||
task_short_name: str,
|
||||
mount_path_on_host: str,
|
||||
llm_config: LLMConfig,
|
||||
agent_config: AgentConfig | None,
|
||||
) -> AppConfig:
|
||||
config = AppConfig(
|
||||
run_as_openhands=False,
|
||||
@@ -61,14 +58,6 @@ def get_config(
|
||||
workspace_mount_path_in_sandbox='/outputs',
|
||||
)
|
||||
config.set_llm_config(llm_config)
|
||||
if agent_config:
|
||||
config.set_agent_config(agent_config)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = AgentConfig(
|
||||
enable_prompt_extensions=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
@@ -159,21 +148,11 @@ def run_solver(
|
||||
os.makedirs(screenshots_dir, exist_ok=True)
|
||||
for image_id, obs in enumerate(state.history):
|
||||
if isinstance(obs, BrowserOutputObservation):
|
||||
image_data = base64.b64decode(
|
||||
obs.screenshot.replace('data:image/png;base64,', '')
|
||||
)
|
||||
image_data = base64.b64decode(obs.screenshot)
|
||||
with open(
|
||||
os.path.join(screenshots_dir, f'{image_id}.png'), 'wb'
|
||||
) as file:
|
||||
file.write(image_data)
|
||||
if obs.set_of_marks:
|
||||
som_image_data = base64.b64decode(
|
||||
obs.set_of_marks.replace('data:image/png;base64,', '')
|
||||
)
|
||||
with open(
|
||||
os.path.join(screenshots_dir, f'{image_id}_som.png'), 'wb'
|
||||
) as file:
|
||||
file.write(som_image_data)
|
||||
|
||||
if save_final_state:
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
@@ -236,10 +215,6 @@ if __name__ == '__main__':
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
agent_config: AgentConfig | None = None
|
||||
if args.agent_config:
|
||||
agent_config = get_agent_config_arg(args.agent_config)
|
||||
|
||||
agent_llm_config: LLMConfig | None = None
|
||||
if args.agent_llm_config:
|
||||
agent_llm_config = get_llm_config_arg(args.agent_llm_config)
|
||||
@@ -280,7 +255,7 @@ if __name__ == '__main__':
|
||||
else:
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
config: AppConfig = get_config(
|
||||
args.task_image_name, task_short_name, temp_dir, agent_llm_config, agent_config
|
||||
args.task_image_name, task_short_name, temp_dir, agent_llm_config
|
||||
)
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
@@ -44,10 +44,6 @@ while [[ $# -gt 0 ]]; do
|
||||
ENV_LLM_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--agent-config)
|
||||
AGENT_CONFIG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--outputs-path)
|
||||
OUTPUTS_PATH="$2"
|
||||
shift 2
|
||||
@@ -129,6 +125,8 @@ temp_file="tasks_${START_PERCENTILE}_${END_PERCENTILE}.md"
|
||||
sed -n "${start_line},${end_line}p" tasks.md > "$temp_file"
|
||||
|
||||
while IFS= read -r task_image; do
|
||||
docker pull $task_image
|
||||
|
||||
# Remove prefix using ## to remove longest matching pattern from start
|
||||
task_name=${task_image##ghcr.io/theagentcompany/}
|
||||
|
||||
@@ -142,23 +140,13 @@ while IFS= read -r task_image; do
|
||||
continue
|
||||
fi
|
||||
|
||||
docker pull $task_image
|
||||
|
||||
# Build the Python command
|
||||
COMMAND="poetry run python run_infer.py \
|
||||
--agent-llm-config \"$AGENT_LLM_CONFIG\" \
|
||||
--env-llm-config \"$ENV_LLM_CONFIG\" \
|
||||
--outputs-path \"$OUTPUTS_PATH\" \
|
||||
--server-hostname \"$SERVER_HOSTNAME\" \
|
||||
--task-image-name \"$task_image\""
|
||||
|
||||
# Add agent-config if it's defined
|
||||
if [ -n "$AGENT_CONFIG" ]; then
|
||||
COMMAND="$COMMAND --agent-config $AGENT_CONFIG"
|
||||
fi
|
||||
|
||||
export PYTHONPATH=evaluation/benchmarks/the_agent_company:$PYTHONPATH && \
|
||||
eval "$COMMAND"
|
||||
export PYTHONPATH=evaluation/benchmarks/the_agent_company:\$PYTHONPATH && \
|
||||
poetry run python run_infer.py \
|
||||
--agent-llm-config "$AGENT_LLM_CONFIG" \
|
||||
--env-llm-config "$ENV_LLM_CONFIG" \
|
||||
--outputs-path "$OUTPUTS_PATH" \
|
||||
--server-hostname "$SERVER_HOSTNAME" \
|
||||
--task-image-name "$task_image"
|
||||
|
||||
# Prune unused images and volumes
|
||||
docker image rm "$task_image"
|
||||
|
||||
@@ -17,7 +17,6 @@ from tqdm import tqdm
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
NoOpCondenserConfig,
|
||||
@@ -44,7 +43,6 @@ from openhands.memory.condenser import get_condensation_metadata
|
||||
class EvalMetadata(BaseModel):
|
||||
agent_class: str
|
||||
llm_config: LLMConfig
|
||||
agent_config: AgentConfig | None = None
|
||||
max_iterations: int
|
||||
eval_output_dir: str
|
||||
start_time: str
|
||||
@@ -169,7 +167,6 @@ def make_metadata(
|
||||
eval_output_dir: str,
|
||||
data_split: str | None = None,
|
||||
details: dict[str, Any] | None = None,
|
||||
agent_config: AgentConfig | None = None,
|
||||
condenser_config: CondenserConfig | None = None,
|
||||
) -> EvalMetadata:
|
||||
model_name = llm_config.model.split('/')[-1]
|
||||
@@ -192,7 +189,6 @@ def make_metadata(
|
||||
metadata = EvalMetadata(
|
||||
agent_class=agent_class,
|
||||
llm_config=llm_config,
|
||||
agent_config=agent_config,
|
||||
max_iterations=max_iterations,
|
||||
eval_output_dir=eval_output_path,
|
||||
start_time=time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { Message } from "#/message";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
|
||||
@@ -18,6 +18,7 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should always render the right options", () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -27,12 +28,30 @@ describe("AccountSettingsContextMenu", () => {
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$SETTINGS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
@@ -48,6 +67,7 @@ describe("AccountSettingsContextMenu", () => {
|
||||
test("onLogout should be disabled if the user is not logged in", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn={false}
|
||||
@@ -63,13 +83,14 @@ describe("AccountSettingsContextMenu", () => {
|
||||
it("should call onClose when clicking outside of the element", async () => {
|
||||
render(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
isLoggedIn
|
||||
/>,
|
||||
);
|
||||
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
const accountSettingsButton = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
await user.click(accountSettingsButton);
|
||||
await user.click(document.body);
|
||||
|
||||
|
||||
+17
-6
@@ -1,6 +1,6 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { render, screen } 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";
|
||||
@@ -8,7 +8,7 @@ import { SettingsProvider } from "#/context/settings-context";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
it("should call saveUserSettings with default settings on confirm reset settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
@@ -26,9 +26,20 @@ describe("AnalyticsConsentFormModal", () => {
|
||||
const confirmButton = screen.getByTestId("confirm-preferences");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveUserSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_consents_to_analytics: true }),
|
||||
);
|
||||
await waitFor(() => expect(onCloseMock).toHaveBeenCalled());
|
||||
expect(saveUserSettingsSpy).toHaveBeenCalledWith({
|
||||
user_consents_to_analytics: true,
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: undefined,
|
||||
language: "en",
|
||||
llm_api_key: undefined,
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: undefined,
|
||||
});
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { AxiosError } from "axios";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
@@ -18,14 +21,161 @@ const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
describe("Sidebar", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
describe("Settings", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("should send all settings data when saving AI configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not reset AI configuration when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const menu = screen.getByTestId("account-settings-context-menu");
|
||||
const accountSettingsButton = within(menu).getByTestId(
|
||||
"account-settings-button",
|
||||
);
|
||||
await user.click(accountSettingsButton);
|
||||
|
||||
const accountSettingsModal = screen.getByTestId("account-settings-form");
|
||||
|
||||
const languageInput =
|
||||
within(accountSettingsModal).getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput =
|
||||
within(accountSettingsModal).getByLabelText(/GITHUB\$TOKEN_LABEL/i);
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const analyticsConsentInput =
|
||||
within(accountSettingsModal).getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton =
|
||||
within(accountSettingsModal).getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
github_token: "new-token",
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not send the api key if its SET", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
|
||||
// Click the advanced options switch to show the API key input
|
||||
const advancedOptionsSwitch = within(settingsModal).getByTestId(
|
||||
"advanced-option-switch",
|
||||
);
|
||||
await user.click(advancedOptionsSwitch);
|
||||
|
||||
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);
|
||||
await user.type(apiKeyInput, "**********");
|
||||
|
||||
const saveButton = within(settingsModal).getByTestId(
|
||||
"save-settings-button",
|
||||
);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should fetch settings data on mount", () => {
|
||||
renderSidebar();
|
||||
expect(getSettingsSpy).toHaveBeenCalled();
|
||||
describe("Settings Modal", () => {
|
||||
it("should open the settings modal if the user clicks the settings button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.queryByTestId("ai-config-modal")).not.toBeInTheDocument();
|
||||
|
||||
const settingsButton = screen.getByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsModal = screen.getByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open the settings modal if GET /settings fails with a 404", async () => {
|
||||
const error = new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
data: { message: "Settings not found" },
|
||||
headers: {},
|
||||
// @ts-expect-error - we only need the response object for this test
|
||||
config: {},
|
||||
},
|
||||
);
|
||||
|
||||
vi.spyOn(OpenHands, "getSettings").mockRejectedValue(error);
|
||||
|
||||
renderSidebar();
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Messages } from "#/components/features/chat/messages";
|
||||
import type { Message } from "#/message";
|
||||
|
||||
describe("File Operations Messages", () => {
|
||||
it("should show success indicator for successful file read operation", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
type: "action",
|
||||
translationID: "read_file_contents",
|
||||
content: "Successfully read file contents",
|
||||
success: true,
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
|
||||
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toBeInTheDocument();
|
||||
expect(statusIcon.closest("svg")).toHaveClass("fill-success");
|
||||
});
|
||||
|
||||
it("should show failure indicator for failed file read operation", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
type: "action",
|
||||
translationID: "read_file_contents",
|
||||
content: "Failed to read file contents",
|
||||
success: false,
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
|
||||
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toBeInTheDocument();
|
||||
expect(statusIcon.closest("svg")).toHaveClass("fill-danger");
|
||||
});
|
||||
|
||||
it("should show success indicator for successful file edit operation", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
type: "action",
|
||||
translationID: "edit_file_contents",
|
||||
content: "Successfully edited file contents",
|
||||
success: true,
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
|
||||
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toBeInTheDocument();
|
||||
expect(statusIcon.closest("svg")).toHaveClass("fill-success");
|
||||
});
|
||||
|
||||
it("should show failure indicator for failed file edit operation", () => {
|
||||
const messages: Message[] = [
|
||||
{
|
||||
type: "action",
|
||||
translationID: "edit_file_contents",
|
||||
content: "Failed to edit file contents",
|
||||
success: false,
|
||||
sender: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
render(<Messages messages={messages} isAwaitingUserConfirmation={false} />);
|
||||
|
||||
const statusIcon = screen.getByTestId("status-icon");
|
||||
expect(statusIcon).toBeInTheDocument();
|
||||
expect(statusIcon.closest("svg")).toHaveClass("fill-danger");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import translations from "../../src/i18n/translation.json";
|
||||
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
|
||||
|
||||
vi.mock("@heroui/react", () => ({
|
||||
vi.mock("@nextui-org/react", () => ({
|
||||
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
|
||||
<div>
|
||||
{children}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
|
||||
describe("AccountSettingsModal", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.skip("should set the appropriate user analytics consent default", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await waitFor(() => expect(analyticsConsentInput).toBeChecked());
|
||||
});
|
||||
|
||||
it("should save the users consent to analytics when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with the analytics consent value if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const analyticsConsentInput = screen.getByTestId("analytics-consent");
|
||||
await user.click(analyticsConsentInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
|
||||
await user.click(analyticsConsentInput);
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should send all settings data when saving account settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const tokenInput = screen.getByTestId("github-token-input");
|
||||
await user.type(tokenInput, "new-token");
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
github_token: "new-token",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should render a checkmark and not the input if the github token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const checkmark = screen.queryByTestId("github-token-set-checkmark");
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
|
||||
expect(checkmark).toBeInTheDocument();
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send an unset github token property when pressing disconnect", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const disconnectButton = await screen.findByTestId("disconnect-github");
|
||||
await user.click(disconnectButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "en",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
unset_github_token: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not unset the github token when changing the language", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
renderWithProviders(<AccountSettingsModal onClose={() => {}} />);
|
||||
|
||||
const languageInput = screen.getByLabelText(/language/i);
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = screen.getByText(/norsk/i);
|
||||
await user.click(norskOption);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
agent: "CodeActAgent",
|
||||
confirmation_mode: false,
|
||||
enable_default_condenser: false,
|
||||
language: "no",
|
||||
llm_base_url: "",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
remote_runtime_resource_factor: 1,
|
||||
security_analyzer: "",
|
||||
user_consents_to_analytics: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
|
||||
describe("BrandButton", () => {
|
||||
const onClickMock = vi.fn();
|
||||
|
||||
it("should set a test id", () => {
|
||||
render(
|
||||
<BrandButton testId="brand-button" type="button" variant="primary">
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("brand-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClick when clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" onClick={onClickMock}>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText("Test Button"));
|
||||
});
|
||||
|
||||
it("should be disabled if isDisabled is true", () => {
|
||||
render(
|
||||
<BrandButton type="button" variant="primary" isDisabled>
|
||||
Test Button
|
||||
</BrandButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Test Button")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ModelSelector } from "#/components/shared/modals/settings/model-selector";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
|
||||
describe("SettingsInput", () => {
|
||||
it("should render an optional tag if showOptionalTag is true", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/optional/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
showOptionalTag
|
||||
label="Test Input"
|
||||
type="text"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/optional/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should disable the input if isDisabled is true", async () => {
|
||||
const { rerender } = render(
|
||||
<SettingsInput testId="test-input" label="Test Input" type="text" />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeEnabled();
|
||||
|
||||
rerender(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
isDisabled
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should set a placeholder on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
placeholder="Test Placeholder"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Test Placeholder",
|
||||
);
|
||||
});
|
||||
|
||||
it("should set a default value on the input", async () => {
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-input")).toHaveValue("Test Value");
|
||||
});
|
||||
|
||||
it("should render start content", async () => {
|
||||
const startContent = <div>Start Content</div>;
|
||||
|
||||
render(
|
||||
<SettingsInput
|
||||
testId="test-input"
|
||||
label="Test Input"
|
||||
type="text"
|
||||
defaultValue="Test Value"
|
||||
startContent={startContent}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Start Content")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
|
||||
describe("SettingsSwitch", () => {
|
||||
it("should call the onChange handler when the input is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleMock = vi.fn();
|
||||
render(
|
||||
<SettingsSwitch testId="test-switch" onToggle={onToggleMock}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
const switchInput = screen.getByTestId("test-switch");
|
||||
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(true);
|
||||
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should render a beta tag if isBeta is true", () => {
|
||||
const { rerender } = render(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta={false}>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/beta/i)).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SettingsSwitch testId="test-switch" onToggle={vi.fn()} isBeta>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/beta/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should be able to set a default toggle state", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onToggleMock = vi.fn();
|
||||
render(
|
||||
<SettingsSwitch
|
||||
testId="test-switch"
|
||||
onToggle={onToggleMock}
|
||||
defaultIsToggled
|
||||
>
|
||||
Test Switch
|
||||
</SettingsSwitch>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("test-switch")).toBeChecked();
|
||||
|
||||
const switchInput = screen.getByTestId("test-switch");
|
||||
await user.click(switchInput);
|
||||
expect(onToggleMock).toHaveBeenCalledWith(false);
|
||||
|
||||
expect(screen.getByTestId("test-switch")).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,36 @@
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen } from "@testing-library/react";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
const onCloseMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "123",
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: () => (
|
||||
<SettingsForm
|
||||
settings={DEFAULT_SETTINGS}
|
||||
models={[DEFAULT_SETTINGS.LLM_MODEL]}
|
||||
models={["anthropic/claude-3-5-sonnet-20241022", "model2"]}
|
||||
agents={["CodeActAgent", "agent2"]}
|
||||
securityAnalyzers={["analyzer1", "analyzer2"]}
|
||||
onClose={onCloseMock}
|
||||
/>
|
||||
),
|
||||
@@ -24,17 +38,39 @@ describe("SettingsForm", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
it("should save the user settings and close the modal when the form is submitted", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouteStub />);
|
||||
it("should not show runtime size selector by default", () => {
|
||||
renderWithProviders(<RouterStub />);
|
||||
expect(screen.queryByText("Runtime Size")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const saveButton = screen.getByRole("button", { name: /save/i });
|
||||
it("should show runtime size selector when advanced options are enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
await screen.findByTestId("runtime-size");
|
||||
});
|
||||
|
||||
it("should not submit the form if required fields are empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub />);
|
||||
|
||||
expect(screen.queryByTestId("custom-model-input")).not.toBeInTheDocument();
|
||||
|
||||
const toggleAdvancedMode = screen.getByTestId("advanced-option-switch");
|
||||
await user.click(toggleAdvancedMode);
|
||||
|
||||
const customModelInput = screen.getByTestId("custom-model-input");
|
||||
expect(customModelInput).toBeInTheDocument();
|
||||
|
||||
await user.clear(customModelInput);
|
||||
|
||||
const saveButton = screen.getByTestId("save-settings-button");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: DEFAULT_SETTINGS.LLM_MODEL,
|
||||
}),
|
||||
);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(onCloseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,14 +14,24 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -37,9 +47,30 @@ describe("UserActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onClickAccountSettings and close the menu when the account settings option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const accountSettingsOption = screen.getByText("ACCOUNT_SETTINGS$SETTINGS");
|
||||
await user.click(accountSettingsOption);
|
||||
|
||||
expect(onClickAccountSettingsMock).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
@@ -58,7 +89,12 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
test("onLogout should not be called when the user is not logged in", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -68,4 +104,21 @@ describe("UserActions", () => {
|
||||
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// FIXME: Spinner now provided through useQuery
|
||||
it.skip("should display the loading spinner", () => {
|
||||
render(
|
||||
<UserActions
|
||||
onClickAccountSettings={onClickAccountSettingsMock}
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
user.click(userAvatar);
|
||||
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
expect(screen.queryByAltText("user avatar")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
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";
|
||||
|
||||
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 }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
result.current.mutate({ LLM_API_KEY: "" });
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: "",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
result.current.mutate({ LLM_API_KEY: null });
|
||||
await waitFor(() => {
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,20 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import i18n from "../../src/i18n";
|
||||
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
|
||||
import { renderWithProviders } from "../../test-utils";
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import i18n from '../../src/i18n';
|
||||
import { AccountSettingsContextMenu } from '../../src/components/features/context-menu/account-settings-context-menu';
|
||||
import { renderWithProviders } from '../../test-utils';
|
||||
|
||||
describe("Translations", () => {
|
||||
it("should render translated text", () => {
|
||||
i18n.changeLanguage("en");
|
||||
describe('Translations', () => {
|
||||
it('should render translated text', () => {
|
||||
i18n.changeLanguage('en');
|
||||
renderWithProviders(
|
||||
<AccountSettingsContextMenu
|
||||
onClickAccountSettings={() => {}}
|
||||
onLogout={() => {}}
|
||||
onClose={() => {}}
|
||||
isLoggedIn
|
||||
/>,
|
||||
isLoggedIn={true}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('account-settings-context-menu')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { screen, waitFor } from "@testing-library/react";
|
||||
import toast from "react-hot-toast";
|
||||
import App from "#/routes/_oh.app/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
|
||||
describe("App", () => {
|
||||
const RouteStub = createRoutesStub([
|
||||
@@ -34,7 +35,7 @@ describe("App", () => {
|
||||
await screen.findByTestId("app-route");
|
||||
});
|
||||
|
||||
it(
|
||||
it.skipIf(!MULTI_CONVERSATION_UI)(
|
||||
"should call endSession if the user does not have permission to view conversation",
|
||||
async () => {
|
||||
const errorToastSpy = vi.spyOn(toast, "error");
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { AxiosError } from "axios";
|
||||
import MainApp from "#/routes/_oh/route";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import Home from "#/routes/_oh._index/route";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
const createAxiosNotFoundErrorObject = () =>
|
||||
new AxiosError(
|
||||
"Request failed with status code 404",
|
||||
"ERR_BAD_REQUEST",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
data: { message: "Settings not found" },
|
||||
headers: {},
|
||||
// @ts-expect-error - we only need the response object for this test
|
||||
config: {},
|
||||
},
|
||||
);
|
||||
|
||||
describe("Home Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
// layout route
|
||||
Component: MainApp,
|
||||
path: "/",
|
||||
children: [
|
||||
{
|
||||
// home route
|
||||
Component: Home,
|
||||
path: "/",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the home screen", () => {
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when the settings button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsButton = await screen.findByTestId("settings-button");
|
||||
await user.click(settingsButton);
|
||||
|
||||
const settingsScreen = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreen).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the settings when pressing 'Connect to GitHub' if the user isn't authenticated", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const connectToGitHubButton =
|
||||
await screen.findByTestId("connect-to-github");
|
||||
await user.click(connectToGitHubButton);
|
||||
|
||||
const settingsScreen = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreen).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Settings 404", () => {
|
||||
it("should open the settings modal if GET /settings fails with a 404", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<RouterStub initialEntries={["/"]} />);
|
||||
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
|
||||
const advancedSettingsButton = await screen.findByTestId(
|
||||
"advanced-settings-link",
|
||||
);
|
||||
await user.click(advancedSettingsButton);
|
||||
|
||||
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
|
||||
expect(settingsModalAfter).not.toBeInTheDocument();
|
||||
|
||||
const settingsScreen = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreen).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,873 +0,0 @@
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { afterEach, 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";
|
||||
import { AuthProvider } from "#/context/auth-context";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { PostApiSettings } from "#/types/settings";
|
||||
import * as ConsentHandlers from "#/utils/handle-capture-consent";
|
||||
|
||||
const toggleAdvancedSettings = async (user: UserEvent) => {
|
||||
const advancedSwitch = await screen.findByTestId("advanced-settings-switch");
|
||||
await user.click(advancedSwitch);
|
||||
};
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
}));
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
path: "/settings",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = () => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={["/settings"]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</AuthProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByText("LLM Settings");
|
||||
screen.getByText("GitHub Settings");
|
||||
screen.getByText("Additional Settings");
|
||||
screen.getByText("Reset to defaults");
|
||||
screen.getByText("Save Changes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Account Settings", () => {
|
||||
it("should render the account settings", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByTestId("github-token-input");
|
||||
screen.getByTestId("github-token-help-anchor");
|
||||
screen.getByTestId("language-input");
|
||||
screen.getByTestId("enable-analytics-switch");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an indicator if the GitHub token is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("github-token-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("unset-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("GitHub token input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an indicator if the GitHub token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = await within(inputParent).findByTestId("set-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("GitHub token input parent not found");
|
||||
}
|
||||
});
|
||||
|
||||
it("should render a disabled 'Disconnect from GitHub' button if the GitHub token is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should render an enabled 'Disconnect from GitHub' button if the GitHub token is set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeEnabled();
|
||||
|
||||
// input should still be rendered
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should logout the user when the 'Disconnect from GitHub' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = await screen.findByText("Disconnect from GitHub");
|
||||
await user.click(button);
|
||||
|
||||
expect(handleLogoutMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not render the 'Configure GitHub Repositories' button if OSS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const button = screen.queryByText("Configure GitHub Repositories");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
APP_SLUG: "test-app",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
await screen.findByText("Configure GitHub Repositories");
|
||||
});
|
||||
|
||||
it("should not render the GitHub token input if SaaS mode", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId("github-token-input");
|
||||
const helpAnchor = screen.queryByTestId("github-token-help-anchor");
|
||||
|
||||
expect(input).not.toBeInTheDocument();
|
||||
expect(helpAnchor).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it.skip("should not reset LLM Provider and Model if GitHub token is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
github_token_is_set: false,
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
});
|
||||
saveSettingsSpy.mockRejectedValueOnce(new Error("Invalid GitHub token"));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
let llmProviderInput = await screen.findByTestId("llm-provider-input");
|
||||
let llmModelInput = await screen.findByTestId("llm-model-input");
|
||||
|
||||
expect(llmProviderInput).toHaveValue("Anthropic");
|
||||
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
|
||||
const input = await screen.findByTestId("github-token-input");
|
||||
await user.type(input, "invalid-token");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
llmProviderInput = await screen.findByTestId("llm-provider-input");
|
||||
llmModelInput = await screen.findByTestId("llm-model-input");
|
||||
|
||||
expect(llmProviderInput).toHaveValue("Anthropic");
|
||||
expect(llmModelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
});
|
||||
|
||||
test("enabling advanced, enabling confirmation mode, and then disabling + enabling advanced should not render the security analyzer input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
let securityAnalyzerInput = screen.queryByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).toBeInTheDocument();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
securityAnalyzerInput = screen.queryByTestId("security-analyzer-input");
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LLM Settings", () => {
|
||||
it("should render the basic LLM settings by default", async () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
screen.getByTestId("advanced-settings-switch");
|
||||
screen.getByTestId("llm-provider-input");
|
||||
screen.getByTestId("llm-model-input");
|
||||
screen.getByTestId("llm-api-key-input");
|
||||
screen.getByTestId("llm-api-key-help-anchor");
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the advanced LLM settings if the advanced switch is toggled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
// Should not render the advanced settings by default
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("base-url-input")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const advancedSwitch = await screen.findByTestId(
|
||||
"advanced-settings-switch",
|
||||
);
|
||||
await user.click(advancedSwitch);
|
||||
|
||||
// Should render the advanced settings
|
||||
expect(
|
||||
screen.queryByTestId("llm-provider-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("llm-model-input")).not.toBeInTheDocument();
|
||||
|
||||
screen.getByTestId("llm-custom-model-input");
|
||||
screen.getByTestId("base-url-input");
|
||||
screen.getByTestId("agent-input");
|
||||
|
||||
// "Invariant" security analyzer
|
||||
screen.getByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// Not rendered until the switch is toggled
|
||||
// screen.getByTestId("security-analyzer-input");
|
||||
});
|
||||
|
||||
it("should render an indicator if the LLM API key is not set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: null,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("unset-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("LLM API Key input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should render an indicator if the LLM API key is set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: "**********",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
const inputParent = input.parentElement;
|
||||
|
||||
if (inputParent) {
|
||||
const badge = within(inputParent).getByTestId("set-indicator");
|
||||
expect(badge).toBeInTheDocument();
|
||||
} else {
|
||||
throw new Error("LLM API Key input parent not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should set asterik placeholder if the LLM API key is set", async () => {
|
||||
getSettingsSpy.mockResolvedValueOnce({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_api_key: "**********",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.getByTestId("llm-api-key-input");
|
||||
expect(input).toHaveProperty("placeholder", "**********");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Basic Model Selector", () => {
|
||||
it("should set the provider and model", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
|
||||
expect(providerInput).toHaveValue("Anthropic");
|
||||
expect(modelInput).toHaveValue("claude-3-5-sonnet-20241022");
|
||||
});
|
||||
});
|
||||
|
||||
it.todo("should change the model values if the provider is changed");
|
||||
|
||||
it.todo("should clear the model values if the provider is cleared");
|
||||
});
|
||||
|
||||
describe("Advanced LLM Settings", () => {
|
||||
it("should not render the runtime settings input if OSS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
const input = screen.queryByTestId("runtime-settings-input");
|
||||
expect(input).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the runtime settings input if SaaS mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
screen.getByTestId("runtime-settings-input");
|
||||
});
|
||||
|
||||
it("should set the default runtime setting set", async () => {
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
remote_runtime_resource_factor: 1,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(userEvent.setup());
|
||||
|
||||
const input = await screen.findByTestId("runtime-settings-input");
|
||||
expect(input).toHaveValue("1x (2 core, 8G)");
|
||||
});
|
||||
|
||||
it("should save the runtime settings when the 'Save Changes' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "123",
|
||||
POSTHOG_CLIENT_KEY: "456",
|
||||
});
|
||||
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const input = await screen.findByTestId("runtime-settings-input");
|
||||
await user.click(input);
|
||||
|
||||
const option = await screen.findByText("2x (4 core, 16G)");
|
||||
await user.click(option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remote_runtime_resource_factor: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("saving with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("resetting settings with no changes but having advanced enabled should hide the advanced items", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("llm-custom-model-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("base-url-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("agent-input")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("security-analyzer-input"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should save if only confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
await toggleAdvancedSettings(user);
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
confirmation_mode: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle advanced if user had set a custom model", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "some/custom-model",
|
||||
});
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
|
||||
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
|
||||
expect(llmCustomInput).toBeInTheDocument();
|
||||
expect(llmCustomInput).toHaveValue("some/custom-model");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have advanced settings enabled if the user previously had them enabled", async () => {
|
||||
const hasAdvancedSettingsSetSpy = vi.spyOn(
|
||||
AdvancedSettingsUtlls,
|
||||
"hasAdvancedSettingsSet",
|
||||
);
|
||||
hasAdvancedSettingsSetSpy.mockReturnValue(true);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
expect(advancedSwitch).toBeChecked();
|
||||
|
||||
const llmCustomInput = screen.getByTestId("llm-custom-model-input");
|
||||
expect(llmCustomInput).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should have confirmation mode enabled if the user previously had it enabled", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
confirmation_mode: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
expect(confirmationModeSwitch).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
// FIXME: security analyzer is not found for some reason...
|
||||
it.skip("should have the values set if the user previously had them set", async () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "no",
|
||||
github_token_is_set: true,
|
||||
user_consents_to_analytics: true,
|
||||
llm_base_url: "https://test.com",
|
||||
llm_model: "anthropic/claude-3-5-sonnet-20241022",
|
||||
agent: "CoActAgent",
|
||||
security_analyzer: "mock-invariant",
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("language-input")).toHaveValue("Norsk");
|
||||
expect(screen.getByText("Disconnect from GitHub")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("enable-analytics-switch")).toBeChecked();
|
||||
expect(screen.getByTestId("advanced-settings-switch")).toBeChecked();
|
||||
expect(screen.getByTestId("base-url-input")).toHaveValue(
|
||||
"https://test.com",
|
||||
);
|
||||
expect(screen.getByTestId("llm-custom-model-input")).toHaveValue(
|
||||
"anthropic/claude-3-5-sonnet-20241022",
|
||||
);
|
||||
expect(screen.getByTestId("agent-input")).toHaveValue("CoActAgent");
|
||||
expect(
|
||||
screen.getByTestId("enable-confirmation-mode-switch"),
|
||||
).toBeChecked();
|
||||
expect(screen.getByTestId("security-analyzer-input")).toHaveValue(
|
||||
"mock-invariant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should save the settings when the 'Save Changes' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_api_key: undefined,
|
||||
github_token: undefined,
|
||||
language: "no",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly save basic LLM model settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
// disable advanced mode
|
||||
const advancedSwitch = await screen.findByTestId(
|
||||
"advanced-settings-switch",
|
||||
);
|
||||
await user.click(advancedSwitch);
|
||||
|
||||
const providerInput = await screen.findByTestId("llm-provider-input");
|
||||
await user.click(providerInput);
|
||||
|
||||
const openaiOption = await screen.findByText("OpenAI");
|
||||
await user.click(openaiOption);
|
||||
|
||||
const modelInput = await screen.findByTestId("llm-model-input");
|
||||
await user.click(modelInput);
|
||||
|
||||
const gpt4Option = await screen.findByText("gpt-4o");
|
||||
await user.click(gpt4Option);
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
github_token: undefined,
|
||||
llm_api_key: undefined,
|
||||
llm_model: "openai/gpt-4o",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should reset the settings when the 'Reset to defaults' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const languageInput = await screen.findByTestId("language-input");
|
||||
await user.click(languageInput);
|
||||
|
||||
const norskOption = await screen.findByText("Norsk");
|
||||
await user.click(norskOption);
|
||||
|
||||
expect(languageInput).toHaveValue("Norsk");
|
||||
|
||||
const resetButton = screen.getByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
|
||||
// show modal
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// confirm reset
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
const mockCopy: Partial<PostApiSettings> = {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
};
|
||||
delete mockCopy.github_token_is_set;
|
||||
delete mockCopy.unset_github_token;
|
||||
delete mockCopy.user_consents_to_analytics;
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith({
|
||||
...mockCopy,
|
||||
github_token: undefined, // not set
|
||||
llm_api_key: "", // reset as well
|
||||
});
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should cancel the reset when the 'Cancel' button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
const cancelButton = within(modal).getByText("Cancel");
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByTestId("reset-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with true if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderSettingsScreen();
|
||||
|
||||
const analyticsConsentInput = await screen.findByTestId(
|
||||
"enable-analytics-switch",
|
||||
);
|
||||
|
||||
expect(analyticsConsentInput).not.toBeChecked();
|
||||
await user.click(analyticsConsentInput);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsent with false if the save is successful", async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
ConsentHandlers,
|
||||
"handleCaptureConsent",
|
||||
);
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = await screen.findByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("should not reset analytics consent when resetting to defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
});
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const analyticsConsentInput = await screen.findByTestId(
|
||||
"enable-analytics-switch",
|
||||
);
|
||||
expect(analyticsConsentInput).toBeChecked();
|
||||
|
||||
const resetButton = await screen.findByText("Reset to defaults");
|
||||
await user.click(resetButton);
|
||||
|
||||
const modal = await screen.findByTestId("reset-modal");
|
||||
const confirmButton = within(modal).getByText("Reset");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user_consents_to_analytics: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the security analyzer input if the confirmation mode is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
let securityAnalyzerInput = screen.queryByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).not.toBeInTheDocument();
|
||||
|
||||
const confirmationModeSwitch = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
await user.click(confirmationModeSwitch);
|
||||
|
||||
securityAnalyzerInput = await screen.findByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
expect(securityAnalyzerInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FIXME: localStorage isn't being set
|
||||
it.skip("should save with ENABLE_DEFAULT_CONDENSER with true if user set the feature flag in local storage", async () => {
|
||||
localStorage.setItem("ENABLE_DEFAULT_CONDENSER", "true");
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderSettingsScreen();
|
||||
|
||||
const saveButton = screen.getByText("Save Changes");
|
||||
await user.click(saveButton);
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enable_default_condenser: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { handleStatusMessage } from "#/services/actions";
|
||||
import store from "#/store";
|
||||
import { trackError } from "#/utils/error-handler";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/utils/error-handler", () => ({
|
||||
trackError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Actions Service", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("handleStatusMessage", () => {
|
||||
it("should dispatch info messages to status state", () => {
|
||||
const message = {
|
||||
type: "info",
|
||||
message: "Runtime is not available",
|
||||
id: "runtime.unavailable",
|
||||
status_update: true as const,
|
||||
};
|
||||
|
||||
handleStatusMessage(message);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: message,
|
||||
}));
|
||||
});
|
||||
|
||||
it("should log error messages and display them in chat", () => {
|
||||
const message = {
|
||||
type: "error",
|
||||
message: "Runtime connection failed",
|
||||
id: "runtime.connection.failed",
|
||||
status_update: true as const,
|
||||
};
|
||||
|
||||
handleStatusMessage(message);
|
||||
|
||||
expect(trackError).toHaveBeenCalledWith({
|
||||
message: "Runtime connection failed",
|
||||
source: "chat",
|
||||
metadata: { msgId: "runtime.connection.failed" },
|
||||
});
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(expect.objectContaining({
|
||||
payload: message,
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,165 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { trackError, showErrorToast, showChatError } from "#/utils/error-handler";
|
||||
import posthog from "posthog-js";
|
||||
import toast from "react-hot-toast";
|
||||
import * as Actions from "#/services/actions";
|
||||
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: {
|
||||
captureException: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/services/actions", () => ({
|
||||
handleStatusMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Error Handler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("trackError", () => {
|
||||
it("should send error to PostHog with basic info", () => {
|
||||
const error = {
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Test error"), {
|
||||
error_source: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("should include additional metadata in PostHog event", () => {
|
||||
const error = {
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
metadata: {
|
||||
extra: "info",
|
||||
details: { foo: "bar" },
|
||||
},
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Test error"), {
|
||||
error_source: "test",
|
||||
extra: "info",
|
||||
details: { foo: "bar" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("showErrorToast", () => {
|
||||
it("should log error and show toast", () => {
|
||||
const error = {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
};
|
||||
|
||||
showErrorToast(error);
|
||||
|
||||
// Verify PostHog logging
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Toast error"), {
|
||||
error_source: "toast-test",
|
||||
});
|
||||
|
||||
// Verify toast was shown
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include metadata in PostHog event when showing toast", () => {
|
||||
const error = {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
metadata: { context: "testing" },
|
||||
};
|
||||
|
||||
showErrorToast(error);
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Toast error"), {
|
||||
error_source: "toast-test",
|
||||
context: "testing",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log errors from different sources with appropriate metadata", () => {
|
||||
// Test agent status error
|
||||
showErrorToast({
|
||||
message: "Agent error",
|
||||
source: "agent-status",
|
||||
metadata: { id: "error.agent" },
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Agent error"), {
|
||||
error_source: "agent-status",
|
||||
id: "error.agent",
|
||||
});
|
||||
|
||||
showErrorToast({
|
||||
message: "Server error",
|
||||
source: "server",
|
||||
metadata: { error_code: 500, details: "Internal error" },
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Server error"), {
|
||||
error_source: "server",
|
||||
error_code: 500,
|
||||
details: "Internal error",
|
||||
});
|
||||
});
|
||||
|
||||
it("should log feedback submission errors with conversation context", () => {
|
||||
const error = new Error("Feedback submission failed");
|
||||
showErrorToast({
|
||||
message: error.message,
|
||||
source: "feedback",
|
||||
metadata: { conversationId: "123", error },
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Feedback submission failed"), {
|
||||
error_source: "feedback",
|
||||
conversationId: "123",
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("showChatError", () => {
|
||||
it("should log error and show chat error message", () => {
|
||||
const error = {
|
||||
message: "Chat error",
|
||||
source: "chat-test",
|
||||
msgId: "123",
|
||||
};
|
||||
|
||||
showChatError(error);
|
||||
|
||||
// Verify PostHog logging
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(new Error("Chat error"), {
|
||||
error_source: "chat-test",
|
||||
});
|
||||
|
||||
// Verify error message was shown in chat
|
||||
expect(Actions.handleStatusMessage).toHaveBeenCalledWith({
|
||||
type: "error",
|
||||
message: "Chat error",
|
||||
id: "123",
|
||||
status_update: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it, test } from "vitest";
|
||||
import { hasAdvancedSettingsSet } from "#/utils/has-advanced-settings-set";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
describe("hasAdvancedSettingsSet", () => {
|
||||
it("should return false by default", () => {
|
||||
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
|
||||
});
|
||||
|
||||
describe("should be true if", () => {
|
||||
test("LLM_BASE_URL is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
LLM_BASE_URL: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("AGENT is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
AGENT: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("CONFIRMATION_MODE is true", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
CONFIRMATION_MODE: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("SECURITY_ANALYZER is set", () => {
|
||||
expect(
|
||||
hasAdvancedSettingsSet({
|
||||
...DEFAULT_SETTINGS,
|
||||
SECURITY_ANALYZER: "test",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isCustomModel } from "#/utils/is-custom-model";
|
||||
|
||||
describe("isCustomModel", () => {
|
||||
const models = ["anthropic/claude-3.5", "openai/gpt-3.5-turbo", "gpt-4o"];
|
||||
|
||||
it("should return false by default", () => {
|
||||
expect(isCustomModel(models, "")).toBe(false);
|
||||
});
|
||||
|
||||
it("should be true if it is a custom model", () => {
|
||||
expect(isCustomModel(models, "some/model")).toBe(true);
|
||||
});
|
||||
|
||||
it("should be false if it is not a custom model", () => {
|
||||
expect(isCustomModel(models, "anthropic/claude-3.5")).toBe(false);
|
||||
expect(isCustomModel(models, "openai/gpt-3.5-turbo")).toBe(false);
|
||||
expect(isCustomModel(models, "openai/gpt-4o")).toBe(false);
|
||||
});
|
||||
});
|
||||
Generated
+2244
-2226
File diff suppressed because it is too large
Load Diff
+20
-20
@@ -1,33 +1,33 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "2.6.14",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.1.5",
|
||||
"@react-router/serve": "^7.1.5",
|
||||
"@nextui-org/react": "^2.6.11",
|
||||
"@react-router/node": "^7.1.3",
|
||||
"@react-router/serve": "^7.1.3",
|
||||
"@react-types/shared": "^3.27.0",
|
||||
"@reduxjs/toolkit": "^2.5.1",
|
||||
"@tanstack/react-query": "^5.66.0",
|
||||
"@tanstack/react-query": "^5.65.1",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.7.9",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.4.2",
|
||||
"framer-motion": "^12.0.6",
|
||||
"i18next": "^24.2.2",
|
||||
"i18next-browser-languagedetector": "^8.0.3",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.22",
|
||||
"jose": "^5.9.4",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.217.6",
|
||||
"posthog-js": "^1.211.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -36,21 +36,21 @@
|
||||
"react-icons": "^5.4.0",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.1.5",
|
||||
"react-router": "^7.1.3",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sirv-cli": "^3.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"vite": "^6.1.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vite": "^6.0.11",
|
||||
"web-vitals": "^3.5.2",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run make-i18n && cross-env VITE_MOCK_API=false react-router dev",
|
||||
"dev:mock": "npm run make-i18n && cross-env VITE_MOCK_API=true react-router dev",
|
||||
"build": "npm run make-i18n && npm run typecheck && react-router build",
|
||||
"build": "npm run make-i18n && tsc && react-router build",
|
||||
"start": "npx sirv-cli build/ --single",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test",
|
||||
@@ -77,15 +77,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mswjs/socket.io-binding": "^0.1.1",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@react-router/dev": "^7.1.5",
|
||||
"@playwright/test": "^1.50.0",
|
||||
"@react-router/dev": "^7.1.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.66.1",
|
||||
"@tanstack/eslint-plugin-query": "^5.65.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.4",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -93,7 +93,7 @@
|
||||
"@types/ws": "^8.5.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/coverage-v8": "^3.0.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.0",
|
||||
@@ -109,8 +109,8 @@
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.4.3",
|
||||
"msw": "^2.6.6",
|
||||
"postcss": "^8.5.2",
|
||||
"prettier": "^3.5.1",
|
||||
"postcss": "^8.5.1",
|
||||
"prettier": "^3.4.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
GetTrajectoryResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
import { ApiSettings } from "#/types/settings";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -229,7 +229,6 @@ class OpenHands {
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
selected_repository: selectedRepository,
|
||||
selected_branch: undefined,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
};
|
||||
@@ -267,9 +266,7 @@ class OpenHands {
|
||||
* Save the settings to the server. Only valid settings are saved.
|
||||
* @param settings - the settings to save
|
||||
*/
|
||||
static async saveSettings(
|
||||
settings: Partial<PostApiSettings>,
|
||||
): Promise<boolean> {
|
||||
static async saveSettings(settings: Partial<ApiSettings>): Promise<boolean> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
@@ -27,10 +27,11 @@ export function AnalyticsConsentFormModal({
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,57 +46,55 @@ export function ExpandableMessage({
|
||||
)}
|
||||
>
|
||||
<div className="text-sm w-full">
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<span
|
||||
className={cn(
|
||||
headline ? "font-bold" : "",
|
||||
type === "error" ? "text-danger" : "text-neutral-300",
|
||||
)}
|
||||
>
|
||||
{headline && (
|
||||
<>
|
||||
{headline}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails ? (
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{type === "action" && success !== undefined && (
|
||||
<span className="flex-shrink-0">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-danger")}
|
||||
/>
|
||||
{headline && (
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<span
|
||||
className={cn(
|
||||
"font-bold",
|
||||
type === "error" ? "text-danger" : "text-neutral-300",
|
||||
)}
|
||||
>
|
||||
{headline}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="cursor-pointer text-left"
|
||||
>
|
||||
{showDetails ? (
|
||||
<ArrowUp
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<ArrowDown
|
||||
className={cn(
|
||||
"h-4 w-4 ml-2 inline",
|
||||
type === "error" ? "fill-danger" : "fill-neutral-300",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(!headline || showDetails) && (
|
||||
{type === "action" && success !== undefined && (
|
||||
<span className="flex-shrink-0">
|
||||
{success ? (
|
||||
<CheckCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-success")}
|
||||
/>
|
||||
) : (
|
||||
<XCircle
|
||||
data-testid="status-icon"
|
||||
className={cn(statusIconClasses, "fill-danger")}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDetails && (
|
||||
<Markdown
|
||||
className="text-sm overflow-auto"
|
||||
components={{
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import type { Message } from "#/message";
|
||||
import { ChatMessage } from "#/components/features/chat/chat-message";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { ImageCarousel } from "../images/image-carousel";
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ContextMenu } from "./context-menu";
|
||||
import { ContextMenuListItem } from "./context-menu-list-item";
|
||||
import { ContextMenuSeparator } from "./context-menu-separator";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
onClose: () => void;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export function AccountSettingsContextMenu({
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
onClose,
|
||||
isLoggedIn,
|
||||
@@ -24,6 +27,13 @@ export function AccountSettingsContextMenu({
|
||||
ref={ref}
|
||||
className="absolute left-full -top-1 z-10"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
testId="account-settings-button"
|
||||
onClick={onClickAccountSettings}
|
||||
>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$SETTINGS)}
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuListItem onClick={onLogout} isDisabled={!isLoggedIn}>
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { showErrorToast } from "#/utils/error-handler";
|
||||
import toast from "react-hot-toast";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { AGENT_STATUS_MAP } from "../../agent-status-map.constant";
|
||||
@@ -27,11 +27,7 @@ export function AgentStatusBar() {
|
||||
}
|
||||
}
|
||||
if (curStatusMessage?.type === "error") {
|
||||
showErrorToast({
|
||||
message,
|
||||
source: "agent-status",
|
||||
metadata: { ...curStatusMessage },
|
||||
});
|
||||
toast.error(message);
|
||||
return;
|
||||
}
|
||||
if (curAgentState === AgentState.LOADING && message.trim()) {
|
||||
|
||||
@@ -22,10 +22,7 @@ export function ConfirmDeleteModal({
|
||||
<BaseModalTitle title="Are you sure you want to delete this project?" />
|
||||
<BaseModalDescription description="All data associated with this project will be lost." />
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
onClick={onConfirm}
|
||||
className="bg-danger font-bold"
|
||||
|
||||
@@ -68,7 +68,6 @@ export function ConversationCard({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDelete?.();
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
||||
@@ -44,16 +44,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedConversationId) {
|
||||
deleteConversation(
|
||||
{ conversationId: selectedConversationId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
deleteConversation({ conversationId: selectedConversationId });
|
||||
setConfirmDeleteModalVisible(false);
|
||||
|
||||
if (cid === selectedConversationId) {
|
||||
endSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,10 +110,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
{confirmDeleteModalVisible && (
|
||||
<ConfirmDeleteModal
|
||||
onConfirm={() => {
|
||||
handleConfirmDelete();
|
||||
setConfirmDeleteModalVisible(false);
|
||||
}}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onCancel={() => setConfirmDeleteModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Autocomplete,
|
||||
AutocompleteItem,
|
||||
AutocompleteSection,
|
||||
} from "@heroui/react";
|
||||
} from "@nextui-org/react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
@@ -31,7 +31,7 @@ export function GitHubRepositorySelector({
|
||||
|
||||
const allRepositories: GitHubRepository[] = [
|
||||
...publicRepositories.filter(
|
||||
(repo) => !userRepositories.find((r) => r.id === repo.id),
|
||||
(repo) => !publicRepositories.find((r) => r.id === repo.id),
|
||||
),
|
||||
...userRepositories,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { SuggestionBox } from "#/components/features/suggestions/suggestion-box";
|
||||
import GitHubLogo from "#/assets/branding/github-logo.svg?react";
|
||||
@@ -11,6 +10,7 @@ import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
|
||||
interface GitHubRepositoriesSuggestionBoxProps {
|
||||
handleSubmit: () => void;
|
||||
@@ -24,7 +24,8 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
user,
|
||||
}: GitHubRepositoriesSuggestionBoxProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [connectToGitHubModalOpen, setConnectToGitHubModalOpen] =
|
||||
React.useState(false);
|
||||
const [searchQuery, setSearchQuery] = React.useState<string>("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
|
||||
@@ -44,33 +45,39 @@ export function GitHubRepositoriesSuggestionBox({
|
||||
if (gitHubAuthUrl) {
|
||||
window.location.href = gitHubAuthUrl;
|
||||
} else {
|
||||
navigate("/settings");
|
||||
setConnectToGitHubModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoggedIn = !!user;
|
||||
|
||||
return (
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
testId="connect-to-github"
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SuggestionBox
|
||||
title={t(I18nKey.LANDING$OPEN_REPO)}
|
||||
content={
|
||||
isLoggedIn ? (
|
||||
<GitHubRepositorySelector
|
||||
onInputChange={setSearchQuery}
|
||||
onSelect={handleSubmit}
|
||||
publicRepositories={searchedRepos || []}
|
||||
userRepositories={repositories}
|
||||
/>
|
||||
) : (
|
||||
<ModalButton
|
||||
text={t(I18nKey.GITHUB$CONNECT)}
|
||||
icon={<GitHubLogo width={20} height={20} />}
|
||||
className="bg-[#791B80] w-full"
|
||||
onClick={handleConnectToGitHub}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{connectToGitHubModalOpen && (
|
||||
<AccountSettingsModal
|
||||
onClose={() => setConnectToGitHubModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandButtonProps {
|
||||
testId?: string;
|
||||
variant: "primary" | "secondary";
|
||||
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function BrandButton({
|
||||
testId,
|
||||
children,
|
||||
variant,
|
||||
type,
|
||||
isDisabled,
|
||||
className,
|
||||
onClick,
|
||||
}: React.PropsWithChildren<BrandButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
data-testid={testId}
|
||||
disabled={isDisabled}
|
||||
// The type is alreadt passed as a prop to the button component
|
||||
// eslint-disable-next-line react/button-has-type
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-fit p-2 rounded disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
variant === "primary" && "bg-[#C9B974] text-[#0D0F11]",
|
||||
variant === "secondary" && "border border-[#C9B974] text-[#C9B974]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
interface HelpLinkProps {
|
||||
testId: string;
|
||||
text: string;
|
||||
linkText: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export function HelpLink({ testId, text, linkText, href }: HelpLinkProps) {
|
||||
return (
|
||||
<p data-testid={testId} className="text-xs">
|
||||
{text}{" "}
|
||||
<a
|
||||
href={href}
|
||||
rel="noreferrer noopener"
|
||||
target="_blank"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{linkText}
|
||||
</a>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import SuccessIcon from "#/icons/success.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface KeyStatusIconProps {
|
||||
isSet: boolean;
|
||||
}
|
||||
|
||||
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
|
||||
return (
|
||||
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
|
||||
<SuccessIcon
|
||||
className={cn(isSet ? "text-[#A5E75E]" : "text-[#E76A5E]")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-[#B7BDC2]">(Optional)</span>;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
|
||||
interface SettingsDropdownInputProps {
|
||||
testId: string;
|
||||
label: string;
|
||||
name: string;
|
||||
items: { key: React.Key; label: string }[];
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
isClearable?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsDropdownInput({
|
||||
testId,
|
||||
label,
|
||||
name,
|
||||
items,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
defaultSelectedKey,
|
||||
isClearable,
|
||||
}: SettingsDropdownInputProps) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2.5 w-[680px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<Autocomplete
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
defaultItems={items}
|
||||
defaultSelectedKey={defaultSelectedKey}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-[#454545] rounded-xl border border-[#717888]",
|
||||
}}
|
||||
inputProps={{
|
||||
classNames: {
|
||||
inputWrapper:
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{(item) => (
|
||||
<AutocompleteItem key={item.key}>{item.label}</AutocompleteItem>
|
||||
)}
|
||||
</Autocomplete>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
|
||||
interface SettingsInputProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
label: string;
|
||||
type: React.HTMLInputTypeAttribute;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
startContent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SettingsInput({
|
||||
testId,
|
||||
name,
|
||||
label,
|
||||
type,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
startContent,
|
||||
className,
|
||||
}: SettingsInputProps) {
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5 w-fit", className)}>
|
||||
<div className="flex items-center gap-2">
|
||||
{startContent}
|
||||
<span className="text-sm">{label}</span>
|
||||
{showOptionalTag && <OptionalTag />}
|
||||
</div>
|
||||
<input
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
type={type}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
className={cn(
|
||||
"bg-[#454545] border border-[#717888] h-10 w-full rounded p-2 placeholder:italic",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from "react";
|
||||
import { StyledSwitchComponent } from "./styled-switch-component";
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
testId?: string;
|
||||
name?: string;
|
||||
onToggle?: (value: boolean) => void;
|
||||
defaultIsToggled?: boolean;
|
||||
isBeta?: boolean;
|
||||
}
|
||||
|
||||
export function SettingsSwitch({
|
||||
children,
|
||||
testId,
|
||||
name,
|
||||
onToggle,
|
||||
defaultIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
setIsToggled(value);
|
||||
onToggle?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 w-fit">
|
||||
<input
|
||||
hidden
|
||||
data-testid={testId}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
onChange={(e) => handleToggle(e.target.checked)}
|
||||
defaultChecked={defaultIsToggled}
|
||||
/>
|
||||
|
||||
<StyledSwitchComponent isToggled={isToggled} />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-[#C9B974] px-1 rounded-full">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface StyledSwitchComponentProps {
|
||||
isToggled: boolean;
|
||||
}
|
||||
|
||||
export function StyledSwitchComponent({
|
||||
isToggled,
|
||||
}: StyledSwitchComponentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-6 rounded-xl flex items-center p-1.5 cursor-pointer",
|
||||
isToggled && "justify-end bg-[#C9B974]",
|
||||
!isToggled && "justify-start bg-[#1F2228] border border-[#B7BDC2]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[#1F2228] w-3 h-3 rounded-xl",
|
||||
isToggled ? "bg-[#1F2228]" : "bg-[#B7BDC2]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { FaListUl } from "react-icons/fa";
|
||||
import { useDispatch } from "react-redux";
|
||||
import posthog from "posthog-js";
|
||||
import toast from "react-hot-toast";
|
||||
import { NavLink } from "react-router";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
import { UserActions } from "./user-actions";
|
||||
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
|
||||
@@ -11,10 +10,12 @@ 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 { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { AccountSettingsModal } from "#/components/shared/modals/account-settings/account-settings-modal";
|
||||
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 { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
|
||||
import { useEndSession } from "#/hooks/use-end-session";
|
||||
import { setCurrentAgentState } from "#/state/agent-slice";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
@@ -29,18 +30,28 @@ 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 { settings, saveUserSettings } = useCurrentSettings();
|
||||
const { saveUserSettings } = useCurrentSettings();
|
||||
|
||||
const [accountSettingsModalOpen, setAccountSettingsModalOpen] =
|
||||
React.useState(false);
|
||||
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
|
||||
|
||||
const [conversationPanelIsOpen, setConversationPanelIsOpen] =
|
||||
React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
// If the github token is invalid, open the account settings modal again
|
||||
if (user.isError) {
|
||||
setAccountSettingsModalOpen(true);
|
||||
}
|
||||
}, [user.isError]);
|
||||
|
||||
React.useEffect(() => {
|
||||
// We don't show toast errors for settings in the global error handler
|
||||
// because we have a special case for 404 errors
|
||||
@@ -52,8 +63,6 @@ export function Sidebar() {
|
||||
toast.error(
|
||||
"Something went wrong while fetching settings. Please reload the page.",
|
||||
);
|
||||
} else if (settingsError?.status === 404) {
|
||||
setSettingsModalIsOpen(true);
|
||||
}
|
||||
}, [settingsError?.status, settingsError, isFetchingSettings]);
|
||||
|
||||
@@ -62,6 +71,10 @@ export function Sidebar() {
|
||||
endSession();
|
||||
};
|
||||
|
||||
const handleAccountSettingsModalClose = () => {
|
||||
setAccountSettingsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (config?.APP_MODE === "saas") await logout();
|
||||
else await saveUserSettings({ unset_github_token: true });
|
||||
@@ -71,12 +84,13 @@ export function Sidebar() {
|
||||
return (
|
||||
<>
|
||||
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
|
||||
<nav className="flex flex-row md:flex-col items-center justify-between h-full">
|
||||
<div className="flex flex-col items-center gap-[26px]">
|
||||
<div className="flex items-center justify-center">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
<nav className="flex flex-row md:flex-col items-center gap-[18px]">
|
||||
<div className="w-[34px] h-[34px] flex items-center justify-center mb-7">
|
||||
<AllHandsLogoButton onClick={handleEndSession} />
|
||||
</div>
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
<ExitProjectButton onClick={handleEndSession} />
|
||||
{MULTI_CONVERSATION_UI && (
|
||||
<TooltipButton
|
||||
testId="toggle-conversation-panel"
|
||||
tooltip="Conversations"
|
||||
@@ -85,28 +99,18 @@ export function Sidebar() {
|
||||
>
|
||||
<FaListUl size={22} />
|
||||
</TooltipButton>
|
||||
<DocsButton />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center gap-[26px] mb-4">
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "text-white" : "text-[#9099AC]"
|
||||
)}
|
||||
<DocsButton />
|
||||
<SettingsButton onClick={() => setSettingsModalIsOpen(true)} />
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
>
|
||||
<SettingsButton />
|
||||
</NavLink>
|
||||
{!user.isLoading && (
|
||||
<UserActions
|
||||
user={
|
||||
user.data ? { avatar_url: user.data.avatar_url } : undefined
|
||||
}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)}
|
||||
{user.isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
onLogout={handleLogout}
|
||||
onClickAccountSettings={() => setAccountSettingsModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{conversationPanelIsOpen && (
|
||||
@@ -118,7 +122,10 @@ export function Sidebar() {
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{settingsModalIsOpen && (
|
||||
{accountSettingsModalOpen && (
|
||||
<AccountSettingsModal onClose={handleAccountSettingsModalClose} />
|
||||
)}
|
||||
{(settingsError?.status === 404 || settingsModalIsOpen) && (
|
||||
<SettingsModal
|
||||
settings={settings}
|
||||
onClose={() => setSettingsModalIsOpen(false)}
|
||||
|
||||
@@ -3,11 +3,16 @@ import { UserAvatar } from "./user-avatar";
|
||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||
|
||||
interface UserActionsProps {
|
||||
onClickAccountSettings: () => void;
|
||||
onLogout: () => void;
|
||||
user?: { avatar_url: string };
|
||||
}
|
||||
|
||||
export function UserActions({ onLogout, user }: UserActionsProps) {
|
||||
export function UserActions({
|
||||
onClickAccountSettings,
|
||||
onLogout,
|
||||
user,
|
||||
}: UserActionsProps) {
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
@@ -19,6 +24,11 @@ export function UserActions({ onLogout, user }: UserActionsProps) {
|
||||
setAccountContextMenuIsVisible(false);
|
||||
};
|
||||
|
||||
const handleClickAccountSettings = () => {
|
||||
onClickAccountSettings();
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
closeAccountMenu();
|
||||
@@ -31,6 +41,7 @@ export function UserActions({ onLogout, user }: UserActionsProps) {
|
||||
{accountContextMenuIsVisible && (
|
||||
<AccountSettingsContextMenu
|
||||
isLoggedIn={!!user}
|
||||
onClickAccountSettings={handleClickAccountSettings}
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import ProfileIcon from "#/icons/profile.svg?react";
|
||||
import DefaultUserAvatar from "#/icons/default-user.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Avatar } from "./avatar";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
@@ -21,17 +21,16 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"w-8 h-8 rounded-full flex items-center justify-center",
|
||||
"w-8 h-8 rounded-full flex items-center justify-center border-2 border-gray-200",
|
||||
isLoading && "bg-transparent",
|
||||
)}
|
||||
>
|
||||
{!isLoading && avatarUrl && <Avatar src={avatarUrl} />}
|
||||
{!isLoading && !avatarUrl && (
|
||||
<ProfileIcon
|
||||
<DefaultUserAvatar
|
||||
aria-label={t(I18nKey.USER$AVATAR_PLACEHOLDER)}
|
||||
width={28}
|
||||
height={28}
|
||||
className="text-[#9099AC]"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConfirmIcon from "#/assets/confirm";
|
||||
import RejectIcon from "#/assets/reject";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface ActionButtonProps {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
|
||||
ariaLabel="All Hands Logo"
|
||||
onClick={onClick}
|
||||
>
|
||||
<AllHandsLogo width={34} height={23} />
|
||||
<AllHandsLogo width={44} height={30} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import DocsIcon from "#/icons/academy.svg?react";
|
||||
import DocsIcon from "#/icons/docs.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
@@ -11,7 +11,7 @@ export function DocsButton() {
|
||||
ariaLabel={t(I18nKey.SIDEBAR$DOCS)}
|
||||
href="https://docs.all-hands.dev"
|
||||
>
|
||||
<DocsIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
<DocsIcon width={28} height={28} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import PlusIcon from "#/icons/plus.svg?react";
|
||||
import NewProjectIcon from "#/icons/new-project.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
|
||||
interface ExitProjectButtonProps {
|
||||
@@ -17,7 +17,7 @@ export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
|
||||
onClick={onClick}
|
||||
testId="new-project-button"
|
||||
>
|
||||
<PlusIcon width={28} height={28} className="text-[#9099AC]" />
|
||||
<NewProjectIcon width={26} height={26} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button } from "@heroui/react";
|
||||
import { Button } from "@nextui-org/react";
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
export interface IconButtonProps {
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { TooltipButton } from "./tooltip-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface SettingsButtonProps {
|
||||
onClick?: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="settings-button"
|
||||
@@ -17,7 +16,7 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<SettingsIcon width={28} height={28} />
|
||||
<FaCog size={24} />
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Tooltip } from "@heroui/react";
|
||||
import { Tooltip } from "@nextui-org/react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
|
||||
interface FormFieldsetProps {
|
||||
id: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Switch } from "@heroui/react";
|
||||
import { Switch } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input, Tooltip } from "@heroui/react";
|
||||
import { Input, Tooltip } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle, FaExclamationCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@heroui/react";
|
||||
import { Input } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Switch } from "@heroui/react";
|
||||
import { Switch } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input } from "@heroui/react";
|
||||
import { Input } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { Autocomplete, AutocompleteItem } from "@nextui-org/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "../confirmation-modals/base-modal";
|
||||
import { ModalBody } from "../modal-body";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
|
||||
import { ModalButton } from "../../buttons/modal-button";
|
||||
import { FormFieldset } from "../../form-fieldset";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useCurrentSettings } from "#/context/settings-context";
|
||||
import { GitHubTokenInput } from "./github-token-input";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
import { useGitHubUser } from "#/hooks/query/use-github-user";
|
||||
|
||||
interface AccountSettingsFormProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountSettingsForm({ onClose }: AccountSettingsFormProps) {
|
||||
const { isError: isGitHubError } = useGitHubUser();
|
||||
const { data: config } = useConfig();
|
||||
const { saveUserSettings, settings } = useCurrentSettings();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const githubTokenIsSet = !!settings?.GITHUB_TOKEN_IS_SET;
|
||||
const analyticsConsentValue = !!settings?.USER_CONSENTS_TO_ANALYTICS;
|
||||
const selectedLanguage = settings?.LANGUAGE || "en";
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const ghToken = formData.get("ghToken")?.toString();
|
||||
const language = formData.get("language")?.toString();
|
||||
const analytics = formData.get("analytics")?.toString() === "on";
|
||||
|
||||
const newSettings: Partial<PostSettings> = {};
|
||||
newSettings.user_consents_to_analytics = analytics;
|
||||
|
||||
if (ghToken) newSettings.github_token = ghToken;
|
||||
|
||||
// The form returns the language label, so we need to find the corresponding
|
||||
// language key to save it in the settings
|
||||
if (language) {
|
||||
const languageKey = AvailableLanguages.find(
|
||||
({ label }) => label === language,
|
||||
)?.value;
|
||||
|
||||
if (languageKey) newSettings.LANGUAGE = languageKey;
|
||||
}
|
||||
|
||||
await saveUserSettings(newSettings, {
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
},
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
const onDisconnect = async () => {
|
||||
await saveUserSettings({ unset_github_token: true });
|
||||
posthog.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBody testID="account-settings-form">
|
||||
<form className="flex flex-col w-full gap-6" onSubmit={handleSubmit}>
|
||||
<div className="w-full flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.ACCOUNT_SETTINGS$TITLE)} />
|
||||
|
||||
{config?.APP_MODE === "saas" && config?.APP_SLUG && (
|
||||
<a
|
||||
href={`https://github.com/apps/${config.APP_SLUG}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="underline"
|
||||
>
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</a>
|
||||
)}
|
||||
<FormFieldset
|
||||
id="language"
|
||||
label={t(I18nKey.LANGUAGE$LABEL)}
|
||||
defaultSelectedKey={selectedLanguage}
|
||||
isClearable={false}
|
||||
items={AvailableLanguages.map(({ label, value: key }) => ({
|
||||
key,
|
||||
value: label,
|
||||
}))}
|
||||
/>
|
||||
|
||||
{config?.APP_MODE !== "saas" && (
|
||||
<>
|
||||
<GitHubTokenInput githubTokenIsSet={githubTokenIsSet} />
|
||||
{!githubTokenIsSet && (
|
||||
<BaseModalDescription>
|
||||
{t(I18nKey.GITHUB$GET_TOKEN)}{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-[#791B80] underline"
|
||||
>
|
||||
{t(I18nKey.COMMON$HERE)}
|
||||
</a>
|
||||
</BaseModalDescription>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isGitHubError && (
|
||||
<p className="text-danger text-xs">
|
||||
{t(I18nKey.GITHUB$TOKEN_INVALID)}
|
||||
</p>
|
||||
)}
|
||||
{githubTokenIsSet && !isGitHubError && (
|
||||
<ModalButton
|
||||
testId="disconnect-github"
|
||||
variant="text-like"
|
||||
text={t(I18nKey.BUTTON$DISCONNECT)}
|
||||
onClick={onDisconnect}
|
||||
className="text-danger self-start"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="flex gap-2 items-center self-start">
|
||||
<input
|
||||
data-testid="analytics-consent"
|
||||
name="analytics"
|
||||
type="checkbox"
|
||||
defaultChecked={analyticsConsentValue}
|
||||
/>
|
||||
{t(I18nKey.ANALYTICS$ENABLE)}
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<ModalButton
|
||||
testId="save-settings"
|
||||
type="submit"
|
||||
intent="account"
|
||||
text={t(I18nKey.BUTTON$SAVE)}
|
||||
className="bg-[#4465DB]"
|
||||
/>
|
||||
<ModalButton
|
||||
text={t(I18nKey.BUTTON$CLOSE)}
|
||||
onClick={onClose}
|
||||
className="bg-[#737373]"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ModalBackdrop } from "../modal-backdrop";
|
||||
import { AccountSettingsForm } from "./account-settings-form";
|
||||
|
||||
interface AccountSettingsModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AccountSettingsModal({ onClose }: AccountSettingsModalProps) {
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<AccountSettingsForm onClose={onClose} />
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCheckCircle } from "react-icons/fa";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitHubTokenInputProps {
|
||||
githubTokenIsSet: boolean;
|
||||
}
|
||||
|
||||
export function GitHubTokenInput({ githubTokenIsSet }: GitHubTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label htmlFor="ghToken" className="flex flex-col gap-2">
|
||||
<span className="text-[11px] leading-4 tracking-[0.5px] font-[500] text-[#A3A3A3] flex items-center gap-1">
|
||||
{githubTokenIsSet && (
|
||||
<FaCheckCircle
|
||||
data-testid="github-token-set-checkmark"
|
||||
size={12}
|
||||
className="text-[#00D1B2]"
|
||||
/>
|
||||
)}
|
||||
{t(I18nKey.GITHUB$TOKEN_LABEL)}
|
||||
<span className="text-[#A3A3A3]">
|
||||
{" "}
|
||||
{t(I18nKey.CUSTOM_INPUT$OPTIONAL_LABEL)}
|
||||
</span>
|
||||
</span>
|
||||
{!githubTokenIsSet && (
|
||||
<input
|
||||
data-testid="github-token-input"
|
||||
id="ghToken"
|
||||
name="ghToken"
|
||||
type="password"
|
||||
className="bg-[#27272A] text-xs py-[10px] px-3 rounded"
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user