Compare commits
5 Commits
test-user
...
chuck-debu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73dcb75217 | ||
|
|
5e6eb60d8d | ||
|
|
ed6e29ca35 | ||
|
|
7e73ba717d | ||
|
|
783496d604 |
7
.github/workflows/fe-unit-tests.yml
vendored
@@ -42,3 +42,10 @@ jobs:
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
- name: Upload source maps as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-source-maps-${{ github.sha }}
|
||||
path: frontend/build/**/*.map
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
76
.github/workflows/frontend-source-maps.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
# Workflow that builds frontend and uploads source maps as artifacts
|
||||
name: Build Frontend Source Maps
|
||||
|
||||
# Run on main branch pushes, releases, and manual triggers
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-source-maps.yml"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-source-maps:
|
||||
name: Build Frontend and Upload Source Maps
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend with source maps
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Upload source maps as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-source-maps-${{ github.sha }}
|
||||
path: frontend/build/**/*.map
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload complete build artifacts (for debugging)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build-${{ github.sha }}
|
||||
path: |
|
||||
frontend/build/**/*
|
||||
!frontend/build/**/*.map
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create source maps summary
|
||||
run: |
|
||||
echo "## Source Maps Generated 🗺️" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Source maps have been generated and uploaded as artifacts." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Statistics:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Total source map files:** $(find frontend/build -name "*.map" | wc -l)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Total size:** $(du -sh frontend/build/**/*.map 2>/dev/null | tail -1 | cut -f1 || echo 'N/A')" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit SHA:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Download:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Source maps are available as workflow artifacts and can be downloaded from the Actions tab." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -8,11 +8,6 @@ description: This page outlines all available configuration options for OpenHand
|
||||
In GUI Mode, any settings applied through the Settings UI will take precedence.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
**Looking for Environment Variables?** All configuration options can also be set using environment variables.
|
||||
See the [Environment Variables Reference](./environment-variables) for a complete list with examples.
|
||||
</Note>
|
||||
|
||||
## Location of the `config.toml` File
|
||||
|
||||
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
|
||||
@@ -23,11 +18,6 @@ specify a different path to the `config.toml` file.
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
Core configuration options can be set as environment variables by converting to uppercase. For example:
|
||||
- `debug` → `DEBUG`
|
||||
- `cache_dir` → `CACHE_DIR`
|
||||
- `runtime` → `RUNTIME`
|
||||
|
||||
### Workspace
|
||||
- `workspace_base` **(Deprecated)**
|
||||
- Type: `str`
|
||||
@@ -151,11 +141,6 @@ The LLM (Large Language Model) configuration options are defined in the `[llm]`
|
||||
|
||||
To use these with the docker command, pass in `-e LLM_<option>`. Example: `-e LLM_NUM_RETRIES`.
|
||||
|
||||
All LLM configuration options can be set as environment variables by prefixing with `LLM_` and converting to uppercase. For example:
|
||||
- `model` → `LLM_MODEL`
|
||||
- `api_key` → `LLM_API_KEY`
|
||||
- `base_url` → `LLM_BASE_URL`
|
||||
|
||||
<Note>
|
||||
For development setups, you can also define custom named LLM configurations. See [Custom LLM Configurations](./llms/custom-llm-configs) for details.
|
||||
</Note>
|
||||
@@ -292,11 +277,6 @@ For development setups, you can also define custom named LLM configurations. See
|
||||
|
||||
The agent configuration options are defined in the `[agent]` and `[agent.<agent_name>]` sections of the `config.toml` file.
|
||||
|
||||
Agent configuration options can be set as environment variables by prefixing with `AGENT_` and converting to uppercase. For example:
|
||||
- `enable_browsing` → `AGENT_ENABLE_BROWSING`
|
||||
- `function_calling` → `AGENT_FUNCTION_CALLING`
|
||||
- `llm_config` → `AGENT_LLM_CONFIG`
|
||||
|
||||
### LLM Configuration
|
||||
- `llm_config`
|
||||
- Type: `str`
|
||||
@@ -348,11 +328,6 @@ The sandbox configuration options are defined in the `[sandbox]` section of the
|
||||
|
||||
To use these with the docker command, pass in `-e SANDBOX_<option>`. Example: `-e SANDBOX_TIMEOUT`.
|
||||
|
||||
All sandbox configuration options can be set as environment variables by prefixing with `SANDBOX_` and converting to uppercase. For example:
|
||||
- `timeout` → `SANDBOX_TIMEOUT`
|
||||
- `user_id` → `SANDBOX_USER_ID`
|
||||
- `base_container_image` → `SANDBOX_BASE_CONTAINER_IMAGE`
|
||||
|
||||
### Execution
|
||||
- `timeout`
|
||||
- Type: `int`
|
||||
@@ -415,10 +390,6 @@ The security configuration options are defined in the `[security]` section of th
|
||||
|
||||
To use these with the docker command, pass in `-e SECURITY_<option>`. Example: `-e SECURITY_CONFIRMATION_MODE`.
|
||||
|
||||
All security configuration options can be set as environment variables by prefixing with `SECURITY_` and converting to uppercase. For example:
|
||||
- `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`
|
||||
- `security_analyzer` → `SECURITY_SECURITY_ANALYZER`
|
||||
|
||||
### Confirmation Mode
|
||||
- `confirmation_mode`
|
||||
- Type: `bool`
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
---
|
||||
title: Environment Variables Reference
|
||||
description: Complete reference of all environment variables supported by OpenHands
|
||||
---
|
||||
|
||||
This page provides a reference of environment variables that can be used to configure OpenHands. Environment variables provide an alternative to TOML configuration files and are particularly useful for containerized deployments, CI/CD pipelines, and cloud environments.
|
||||
|
||||
## Environment Variable Naming Convention
|
||||
|
||||
OpenHands follows a consistent naming pattern for environment variables:
|
||||
|
||||
- **Core settings**: Direct uppercase mapping (e.g., `debug` → `DEBUG`)
|
||||
- **LLM settings**: Prefixed with `LLM_` (e.g., `model` → `LLM_MODEL`)
|
||||
- **Agent settings**: Prefixed with `AGENT_` (e.g., `enable_browsing` → `AGENT_ENABLE_BROWSING`)
|
||||
- **Sandbox settings**: Prefixed with `SANDBOX_` (e.g., `timeout` → `SANDBOX_TIMEOUT`)
|
||||
- **Security settings**: Prefixed with `SECURITY_` (e.g., `confirmation_mode` → `SECURITY_CONFIRMATION_MODE`)
|
||||
|
||||
## Core Configuration Variables
|
||||
|
||||
These variables correspond to the `[core]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `DEBUG` | boolean | `false` | Enable debug logging throughout the application |
|
||||
| `DISABLE_COLOR` | boolean | `false` | Disable colored output in terminal |
|
||||
| `CACHE_DIR` | string | `"/tmp/cache"` | Directory path for caching |
|
||||
| `SAVE_TRAJECTORY_PATH` | string | `"./trajectories"` | Path to store conversation trajectories |
|
||||
| `REPLAY_TRAJECTORY_PATH` | string | `""` | Path to load and replay a trajectory file |
|
||||
| `FILE_STORE_PATH` | string | `"/tmp/file_store"` | File store directory path |
|
||||
| `FILE_STORE` | string | `"memory"` | File store type (`memory`, `local`, etc.) |
|
||||
| `FILE_UPLOADS_MAX_FILE_SIZE_MB` | integer | `0` | Maximum file upload size in MB (0 = no limit) |
|
||||
| `FILE_UPLOADS_RESTRICT_FILE_TYPES` | boolean | `false` | Whether to restrict file upload types |
|
||||
| `FILE_UPLOADS_ALLOWED_EXTENSIONS` | list | `[".*"]` | List of allowed file extensions for uploads |
|
||||
| `MAX_BUDGET_PER_TASK` | float | `0.0` | Maximum budget per task (0.0 = no limit) |
|
||||
| `MAX_ITERATIONS` | integer | `100` | Maximum number of iterations per task |
|
||||
| `RUNTIME` | string | `"docker"` | Runtime environment (`docker`, `local`, `cli`, etc.) |
|
||||
| `DEFAULT_AGENT` | string | `"CodeActAgent"` | Default agent class to use |
|
||||
| `JWT_SECRET` | string | auto-generated | JWT secret for authentication |
|
||||
| `RUN_AS_OPENHANDS` | boolean | `true` | Whether to run as the openhands user |
|
||||
| `VOLUMES` | string | `""` | Volume mounts in format `host:container[:mode]` |
|
||||
|
||||
## LLM Configuration Variables
|
||||
|
||||
These variables correspond to the `[llm]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `LLM_MODEL` | string | `"claude-3-5-sonnet-20241022"` | LLM model to use |
|
||||
| `LLM_API_KEY` | string | `""` | API key for the LLM provider |
|
||||
| `LLM_BASE_URL` | string | `""` | Custom API base URL |
|
||||
| `LLM_API_VERSION` | string | `""` | API version to use |
|
||||
| `LLM_TEMPERATURE` | float | `0.0` | Sampling temperature |
|
||||
| `LLM_TOP_P` | float | `1.0` | Top-p sampling parameter |
|
||||
| `LLM_MAX_INPUT_TOKENS` | integer | `0` | Maximum input tokens (0 = no limit) |
|
||||
| `LLM_MAX_OUTPUT_TOKENS` | integer | `0` | Maximum output tokens (0 = no limit) |
|
||||
| `LLM_MAX_MESSAGE_CHARS` | integer | `30000` | Maximum characters that will be sent to the model in observation content |
|
||||
| `LLM_TIMEOUT` | integer | `0` | API timeout in seconds (0 = no timeout) |
|
||||
| `LLM_NUM_RETRIES` | integer | `8` | Number of retry attempts |
|
||||
| `LLM_RETRY_MIN_WAIT` | integer | `15` | Minimum wait time between retries (seconds) |
|
||||
| `LLM_RETRY_MAX_WAIT` | integer | `120` | Maximum wait time between retries (seconds) |
|
||||
| `LLM_RETRY_MULTIPLIER` | float | `2.0` | Exponential backoff multiplier |
|
||||
| `LLM_DROP_PARAMS` | boolean | `false` | Drop unsupported parameters without error |
|
||||
| `LLM_CACHING_PROMPT` | boolean | `true` | Enable prompt caching if supported |
|
||||
| `LLM_DISABLE_VISION` | boolean | `false` | Disable vision capabilities for cost reduction |
|
||||
| `LLM_CUSTOM_LLM_PROVIDER` | string | `""` | Custom LLM provider name |
|
||||
| `LLM_OLLAMA_BASE_URL` | string | `""` | Base URL for Ollama API |
|
||||
| `LLM_INPUT_COST_PER_TOKEN` | float | `0.0` | Cost per input token |
|
||||
| `LLM_OUTPUT_COST_PER_TOKEN` | float | `0.0` | Cost per output token |
|
||||
| `LLM_REASONING_EFFORT` | string | `""` | Reasoning effort for o-series models (`low`, `medium`, `high`) |
|
||||
|
||||
### AWS Configuration
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `LLM_AWS_ACCESS_KEY_ID` | string | `""` | AWS access key ID |
|
||||
| `LLM_AWS_SECRET_ACCESS_KEY` | string | `""` | AWS secret access key |
|
||||
| `LLM_AWS_REGION_NAME` | string | `""` | AWS region name |
|
||||
|
||||
## Agent Configuration Variables
|
||||
|
||||
These variables correspond to the `[agent]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `AGENT_LLM_CONFIG` | string | `""` | Name of LLM config group to use |
|
||||
| `AGENT_FUNCTION_CALLING` | boolean | `true` | Enable function calling |
|
||||
| `AGENT_ENABLE_BROWSING` | boolean | `false` | Enable browsing delegate |
|
||||
| `AGENT_ENABLE_LLM_EDITOR` | boolean | `false` | Enable LLM-based editor |
|
||||
| `AGENT_ENABLE_JUPYTER` | boolean | `false` | Enable Jupyter integration |
|
||||
| `AGENT_ENABLE_HISTORY_TRUNCATION` | boolean | `true` | Enable history truncation |
|
||||
| `AGENT_ENABLE_PROMPT_EXTENSIONS` | boolean | `true` | Enable microagents (prompt extensions) |
|
||||
| `AGENT_DISABLED_MICROAGENTS` | list | `[]` | List of microagents to disable |
|
||||
|
||||
## Sandbox Configuration Variables
|
||||
|
||||
These variables correspond to the `[sandbox]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_TIMEOUT` | integer | `120` | Sandbox timeout in seconds |
|
||||
| `SANDBOX_USER_ID` | integer | `1000` | User ID for sandbox processes |
|
||||
| `SANDBOX_BASE_CONTAINER_IMAGE` | string | `"nikolaik/python-nodejs:python3.12-nodejs22"` | Base container image |
|
||||
| `SANDBOX_USE_HOST_NETWORK` | boolean | `false` | Use host networking |
|
||||
| `SANDBOX_RUNTIME_BINDING_ADDRESS` | string | `"0.0.0.0"` | Runtime binding address |
|
||||
| `SANDBOX_ENABLE_AUTO_LINT` | boolean | `false` | Enable automatic linting |
|
||||
| `SANDBOX_INITIALIZE_PLUGINS` | boolean | `true` | Initialize sandbox plugins |
|
||||
| `SANDBOX_RUNTIME_EXTRA_DEPS` | string | `""` | Extra dependencies to install |
|
||||
| `SANDBOX_RUNTIME_STARTUP_ENV_VARS` | dict | `{}` | Environment variables for runtime |
|
||||
| `SANDBOX_BROWSERGYM_EVAL_ENV` | string | `""` | BrowserGym evaluation environment |
|
||||
| `SANDBOX_VOLUMES` | string | `""` | Volume mounts (replaces deprecated workspace settings) |
|
||||
| `SANDBOX_RUNTIME_CONTAINER_IMAGE` | string | `""` | Pre-built runtime container image |
|
||||
| `SANDBOX_KEEP_RUNTIME_ALIVE` | boolean | `false` | Keep runtime alive after session ends |
|
||||
| `SANDBOX_PAUSE_CLOSED_RUNTIMES` | boolean | `false` | Pause instead of stopping closed runtimes |
|
||||
| `SANDBOX_CLOSE_DELAY` | integer | `300` | Delay before closing idle runtimes (seconds) |
|
||||
| `SANDBOX_RM_ALL_CONTAINERS` | boolean | `false` | Remove all containers when stopping |
|
||||
| `SANDBOX_ENABLE_GPU` | boolean | `false` | Enable GPU support |
|
||||
| `SANDBOX_CUDA_VISIBLE_DEVICES` | string | `""` | Specify GPU devices by ID |
|
||||
| `SANDBOX_VSCODE_PORT` | integer | auto | Specific port for VSCode server |
|
||||
|
||||
### Sandbox Environment Variables
|
||||
Variables prefixed with `SANDBOX_ENV_` are passed through to the sandbox environment:
|
||||
|
||||
| Environment Variable | Description |
|
||||
|---------------------|-------------|
|
||||
| `SANDBOX_ENV_*` | Any variable with this prefix is passed to the sandbox (e.g., `SANDBOX_ENV_OPENAI_API_KEY`) |
|
||||
|
||||
## Security Configuration Variables
|
||||
|
||||
These variables correspond to the `[security]` section in `config.toml`:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SECURITY_CONFIRMATION_MODE` | boolean | `false` | Enable confirmation mode for actions |
|
||||
| `SECURITY_SECURITY_ANALYZER` | string | `"llm"` | Security analyzer to use (`llm`, `invariant`) |
|
||||
| `SECURITY_ENABLE_SECURITY_ANALYZER` | boolean | `true` | Enable security analysis |
|
||||
|
||||
## Debug and Logging Variables
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `DEBUG` | boolean | `false` | Enable general debug logging |
|
||||
| `DEBUG_LLM` | boolean | `false` | Enable LLM-specific debug logging |
|
||||
| `DEBUG_RUNTIME` | boolean | `false` | Enable runtime debug logging |
|
||||
| `LOG_TO_FILE` | boolean | auto | Log to file (auto-enabled when DEBUG=true) |
|
||||
|
||||
## Runtime-Specific Variables
|
||||
|
||||
### Docker Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_VOLUME_OVERLAYS` | string | `""` | Volume overlay configurations |
|
||||
|
||||
### Remote Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `SANDBOX_API_KEY` | string | `""` | API key for remote runtime |
|
||||
| `SANDBOX_REMOTE_RUNTIME_API_URL` | string | `""` | Remote runtime API URL |
|
||||
|
||||
### Local Runtime
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `RUNTIME_URL` | string | `""` | Runtime URL for local runtime |
|
||||
| `RUNTIME_URL_PATTERN` | string | `""` | Runtime URL pattern |
|
||||
| `RUNTIME_ID` | string | `""` | Runtime identifier |
|
||||
| `LOCAL_RUNTIME_MODE` | string | `""` | Enable local runtime mode (`1` to enable) |
|
||||
|
||||
## Integration Variables
|
||||
|
||||
### GitHub Integration
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `GITHUB_TOKEN` | string | `""` | GitHub personal access token |
|
||||
|
||||
### Third-Party API Keys
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `OPENAI_API_KEY` | string | `""` | OpenAI API key |
|
||||
| `ANTHROPIC_API_KEY` | string | `""` | Anthropic API key |
|
||||
| `GOOGLE_API_KEY` | string | `""` | Google API key |
|
||||
| `AZURE_API_KEY` | string | `""` | Azure API key |
|
||||
| `TAVILY_API_KEY` | string | `""` | Tavily search API key |
|
||||
|
||||
## Server Configuration Variables
|
||||
|
||||
These are primarily used when running OpenHands as a server:
|
||||
|
||||
| Environment Variable | Type | Default | Description |
|
||||
|---------------------|------|---------|-------------|
|
||||
| `FRONTEND_PORT` | integer | `3000` | Frontend server port |
|
||||
| `BACKEND_PORT` | integer | `8000` | Backend server port |
|
||||
| `FRONTEND_HOST` | string | `"localhost"` | Frontend host address |
|
||||
| `BACKEND_HOST` | string | `"localhost"` | Backend host address |
|
||||
| `WEB_HOST` | string | `"localhost"` | Web server host |
|
||||
| `SERVE_FRONTEND` | boolean | `true` | Whether to serve frontend |
|
||||
|
||||
## Deprecated Variables
|
||||
|
||||
These variables are deprecated and should be replaced:
|
||||
|
||||
| Environment Variable | Replacement | Description |
|
||||
|---------------------|-------------|-------------|
|
||||
| `WORKSPACE_BASE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_PATH` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_PATH_IN_SANDBOX` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
| `WORKSPACE_MOUNT_REWRITE` | `SANDBOX_VOLUMES` | Use volume mounting instead |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Setup with OpenAI
|
||||
```bash
|
||||
export LLM_MODEL="gpt-4o"
|
||||
export LLM_API_KEY="your-openai-api-key"
|
||||
export DEBUG=true
|
||||
```
|
||||
|
||||
### Docker Deployment with Custom Volumes
|
||||
```bash
|
||||
export RUNTIME="docker"
|
||||
export SANDBOX_VOLUMES="/host/workspace:/workspace:rw,/host/data:/data:ro"
|
||||
export SANDBOX_TIMEOUT=300
|
||||
```
|
||||
|
||||
### Remote Runtime Configuration
|
||||
```bash
|
||||
export RUNTIME="remote"
|
||||
export SANDBOX_API_KEY="your-remote-api-key"
|
||||
export SANDBOX_REMOTE_RUNTIME_API_URL="https://your-runtime-api.com"
|
||||
```
|
||||
|
||||
### Security-Enhanced Setup
|
||||
```bash
|
||||
export SECURITY_CONFIRMATION_MODE=true
|
||||
export SECURITY_SECURITY_ANALYZER="llm"
|
||||
export DEBUG_RUNTIME=true
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
1. **Boolean Values**: Environment variables expecting boolean values accept `true`/`false`, `1`/`0`, or `yes`/`no` (case-insensitive).
|
||||
|
||||
2. **List Values**: Lists should be provided as Python literal strings, e.g., `AGENT_DISABLED_MICROAGENTS='["microagent1", "microagent2"]'`.
|
||||
|
||||
3. **Dictionary Values**: Dictionaries should be provided as Python literal strings, e.g., `SANDBOX_RUNTIME_STARTUP_ENV_VARS='{"KEY": "value"}'`.
|
||||
|
||||
4. **Precedence**: Environment variables take precedence over TOML configuration files.
|
||||
|
||||
5. **Docker Usage**: When using Docker, pass environment variables with the `-e` flag:
|
||||
```bash
|
||||
docker run -e LLM_API_KEY="your-key" -e DEBUG=true openhands/openhands
|
||||
```
|
||||
|
||||
6. **Validation**: Invalid environment variable values will be logged as errors and fall back to defaults.
|
||||
@@ -46,8 +46,7 @@ repos:
|
||||
- types-toml
|
||||
- types-redis
|
||||
- lxml
|
||||
# OpenHands package in repo root
|
||||
- ./
|
||||
# TODO: Add OpenHands in parent
|
||||
- stripe==11.5.0
|
||||
- pygithub==2.6.1
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
|
||||
@@ -7,11 +7,15 @@ warn_unreachable = True
|
||||
warn_redundant_casts = True
|
||||
no_implicit_optional = True
|
||||
strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
exclude = (^enterprise/migrations/.*)
|
||||
exclude = (^enterprise/migrations/.*|^openhands/.*)
|
||||
|
||||
[mypy-enterprise.tests.unit.test_auth_routes.*]
|
||||
disable_error_code = union-attr
|
||||
|
||||
[mypy-enterprise.sync.install_gitlab_webhooks.*]
|
||||
disable_error_code = redundant-cast
|
||||
|
||||
# Let the other config check base openhands packages
|
||||
[mypy-openhands.*]
|
||||
follow_imports = skip
|
||||
ignore_missing_imports = True
|
||||
|
||||
@@ -55,7 +55,7 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
|
||||
@staticmethod
|
||||
def run_config_variant_test(
|
||||
user_id: str | None, conversation_id: str, config: OpenHandsConfig
|
||||
user_id: str, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
"""
|
||||
Run agent config variant test and potentially modify the OpenHands config
|
||||
|
||||
@@ -62,13 +62,7 @@ class GitlabManager(Manager):
|
||||
logger.warning(f'Got invalid keyloak user id for GitLab User {user_id}')
|
||||
return False
|
||||
|
||||
# Importing here prevents circular import
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
|
||||
external_auth_id=keycloak_user_id
|
||||
)
|
||||
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
|
||||
return await gitlab_service.user_has_write_access(project_id)
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
@@ -125,13 +119,7 @@ class GitlabManager(Manager):
|
||||
gitlab_view: The GitLab view object containing issue/PR/comment info
|
||||
"""
|
||||
keycloak_user_id = gitlab_view.user_info.keycloak_user_id
|
||||
|
||||
# Importing here prevents circular import
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
gitlab_service: SaaSGitLabService = GitLabServiceImpl(
|
||||
external_auth_id=keycloak_user_id
|
||||
)
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=keycloak_user_id)
|
||||
|
||||
outgoing_message = message.message
|
||||
|
||||
|
||||
@@ -47,14 +47,14 @@ class GitlabIssue(ResolverViewInterface):
|
||||
)
|
||||
|
||||
self.previous_comments = await gitlab_service.get_issue_or_mr_comments(
|
||||
str(self.project_id), self.issue_number, is_mr=self.is_mr
|
||||
self.project_id, self.issue_number, is_mr=self.is_mr
|
||||
)
|
||||
|
||||
(
|
||||
self.title,
|
||||
self.description,
|
||||
) = await gitlab_service.get_issue_or_mr_title_and_body(
|
||||
str(self.project_id), self.issue_number, is_mr=self.is_mr
|
||||
self.project_id, self.issue_number, is_mr=self.is_mr
|
||||
)
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
@@ -199,11 +199,11 @@ class GitlabInlineMRComment(GitlabMRComment):
|
||||
self.title,
|
||||
self.description,
|
||||
) = await gitlab_service.get_issue_or_mr_title_and_body(
|
||||
str(self.project_id), self.issue_number, is_mr=self.is_mr
|
||||
self.project_id, self.issue_number, is_mr=self.is_mr
|
||||
)
|
||||
|
||||
self.previous_comments = await gitlab_service.get_review_thread_comments(
|
||||
str(self.project_id), self.issue_number, self.discussion_id
|
||||
self.project_id, self.issue_number, self.discussion_id
|
||||
)
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
|
||||
@@ -172,17 +172,6 @@ def get_summary_for_agent_state(
|
||||
|
||||
return f'OpenHands encountered an error: **{reason}**.\n\n[See the conversation]({conversation_link}) for more information.'
|
||||
|
||||
if state == AgentState.AWAITING_USER_INPUT:
|
||||
logger.info(
|
||||
'Agent is awaiting user input',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return f'OpenHands is waiting for your input. [Continue the conversation]({conversation_link}) to provide additional instructions.'
|
||||
|
||||
# Log unknown agent state as error
|
||||
logger.error(
|
||||
'Unknown error: Unhandled agent state',
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""add cancellation fields to subscription_access
|
||||
|
||||
Revision ID: 075
|
||||
Revises: 074
|
||||
Create Date: 2025-01-11
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '075'
|
||||
down_revision: Union[str, None] = '074'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add cancelled_at field to track cancellation timestamp
|
||||
op.add_column(
|
||||
'subscription_access',
|
||||
sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
# Add stripe_subscription_id field to enable cancellation via Stripe API
|
||||
op.add_column(
|
||||
'subscription_access',
|
||||
sa.Column('stripe_subscription_id', sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
# Create index on stripe_subscription_id for efficient lookups
|
||||
op.create_index(
|
||||
'ix_subscription_access_stripe_subscription_id',
|
||||
'subscription_access',
|
||||
['stripe_subscription_id'],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop index
|
||||
op.drop_index(
|
||||
'ix_subscription_access_stripe_subscription_id', 'subscription_access'
|
||||
)
|
||||
|
||||
# Drop columns
|
||||
op.drop_column('subscription_access', 'stripe_subscription_id')
|
||||
op.drop_column('subscription_access', 'cancelled_at')
|
||||
@@ -17,13 +17,11 @@ from server.constants import (
|
||||
STRIPE_API_KEY,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SUBSCRIPTION_PRICE_DATA,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from server.logger import logger
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.database import session_maker
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
@@ -44,8 +42,6 @@ class SubscriptionAccessResponse(BaseModel):
|
||||
start_at: datetime
|
||||
end_at: datetime
|
||||
created_at: datetime
|
||||
cancelled_at: datetime | None = None
|
||||
stripe_subscription_id: str | None = None
|
||||
|
||||
|
||||
class CreateCheckoutSessionRequest(BaseModel):
|
||||
@@ -89,7 +85,7 @@ async def get_credits(user_id: str = Depends(get_user_id)) -> GetCreditsResponse
|
||||
async def get_subscription_access(
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> SubscriptionAccessResponse | None:
|
||||
"""Get details of the currently valid subscription for the user."""
|
||||
"""Get details of the currently valid subscription for the user"""
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
subscription_access = (
|
||||
@@ -106,8 +102,6 @@ async def get_subscription_access(
|
||||
start_at=subscription_access.start_at,
|
||||
end_at=subscription_access.end_at,
|
||||
created_at=subscription_access.created_at,
|
||||
cancelled_at=subscription_access.cancelled_at,
|
||||
stripe_subscription_id=subscription_access.stripe_subscription_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,78 +113,6 @@ async def has_payment_method(user_id: str = Depends(get_user_id)) -> bool:
|
||||
return await stripe_service.has_payment_method(user_id)
|
||||
|
||||
|
||||
# Endpoint to cancel user's subscription
|
||||
@billing_router.post('/cancel-subscription')
|
||||
async def cancel_subscription(user_id: str = Depends(get_user_id)) -> JSONResponse:
|
||||
"""Cancel user's active subscription at the end of the current billing period."""
|
||||
if not user_id:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
with session_maker() as session:
|
||||
# Find the user's active subscription
|
||||
now = datetime.now(UTC)
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not already cancelled
|
||||
.first()
|
||||
)
|
||||
|
||||
if not subscription_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No active subscription found',
|
||||
)
|
||||
|
||||
if not subscription_access.stripe_subscription_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot cancel subscription: missing Stripe subscription ID',
|
||||
)
|
||||
|
||||
try:
|
||||
# Cancel the subscription in Stripe at period end
|
||||
await stripe.Subscription.modify_async(
|
||||
subscription_access.stripe_subscription_id, cancel_at_period_end=True
|
||||
)
|
||||
|
||||
# Update local database
|
||||
subscription_access.cancelled_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'subscription_cancelled',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'stripe_subscription_id': subscription_access.stripe_subscription_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
'end_at': subscription_access.end_at,
|
||||
},
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{'status': 'success', 'message': 'Subscription cancelled successfully'}
|
||||
)
|
||||
|
||||
except stripe.StripeError as e:
|
||||
logger.error(
|
||||
'stripe_cancellation_failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'stripe_subscription_id': subscription_access.stripe_subscription_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Failed to cancel subscription: {str(e)}',
|
||||
)
|
||||
|
||||
|
||||
# Endpoint to create a new setup intent in stripe
|
||||
@billing_router.post('/create-customer-setup-session')
|
||||
async def create_customer_setup_session(
|
||||
@@ -268,27 +190,9 @@ async def create_subscription_checkout_session(
|
||||
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> CreateBillingSessionResponse:
|
||||
# Prevent duplicate subscriptions for the same user
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
existing_active_subscription = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.filter(SubscriptionAccess.cancelled_at.is_(None)) # Not cancelled
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_active_subscription:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Cannot create subscription: User already has an active subscription that has not been cancelled',
|
||||
)
|
||||
|
||||
customer_id = await stripe_service.find_or_create_customer(user_id)
|
||||
subscription_price_data = SUBSCRIPTION_PRICE_DATA[billing_session_type.value]
|
||||
# TODO: Prevent duplicate subscriptions for the same user
|
||||
checkout_session = await stripe.checkout.Session.create_async(
|
||||
customer=customer_id,
|
||||
line_items=[
|
||||
@@ -342,7 +246,7 @@ async def create_subscription_checkout_session_via_get(
|
||||
billing_session_type: BillingSessionType = BillingSessionType.MONTHLY_SUBSCRIPTION,
|
||||
user_id: str = Depends(get_user_id),
|
||||
) -> RedirectResponse:
|
||||
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)."""
|
||||
"""Create a subscription checkout session using a GET request (For easier copy / paste to URL bar)"""
|
||||
response = await create_subscription_checkout_session(
|
||||
request, billing_session_type, user_id
|
||||
)
|
||||
@@ -374,7 +278,7 @@ async def success_callback(session_id: str, request: Request):
|
||||
!= BillingSessionType.DIRECT_PAYMENT.value
|
||||
):
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=success', status_code=302
|
||||
f'{request.base_url}settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
stripe_session = stripe.checkout.Session.retrieve(session_id)
|
||||
@@ -444,29 +348,14 @@ async def cancel_callback(session_id: str, request: Request):
|
||||
session.merge(billing_session)
|
||||
session.commit()
|
||||
|
||||
# Redirect credit purchases to billing screen, subscriptions to LLM settings
|
||||
if (
|
||||
billing_session.billing_session_type
|
||||
== BillingSessionType.DIRECT_PAYMENT.value
|
||||
):
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings/billing?checkout=cancel',
|
||||
status_code=302,
|
||||
)
|
||||
else:
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
# If no billing session found, default to LLM settings (subscription flow)
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings?checkout=cancel', status_code=302
|
||||
f'{request.base_url}settings/billing?checkout=cancel', status_code=302
|
||||
)
|
||||
|
||||
|
||||
@billing_router.post('/stripe-webhook')
|
||||
async def stripe_webhook(request: Request) -> JSONResponse:
|
||||
"""Endpoint for stripe webhooks."""
|
||||
"""Endpoint for stripe webhooks"""
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get('stripe-signature')
|
||||
|
||||
@@ -508,111 +397,15 @@ async def stripe_webhook(request: Request) -> JSONResponse:
|
||||
end_at=end_at,
|
||||
amount_paid=amount_paid,
|
||||
stripe_invoice_payment_id=invoice.payment_intent,
|
||||
stripe_subscription_id=invoice.subscription, # Store Stripe subscription ID
|
||||
)
|
||||
session.add(subscription_access)
|
||||
session.commit()
|
||||
elif event_type == 'customer.subscription.updated':
|
||||
subscription = event['data']['object']
|
||||
subscription_id = subscription['id']
|
||||
|
||||
# Handle subscription cancellation
|
||||
if subscription.get('cancel_at_period_end') is True:
|
||||
with session_maker() as session:
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(
|
||||
SubscriptionAccess.stripe_subscription_id == subscription_id
|
||||
)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription_access and not subscription_access.cancelled_at:
|
||||
subscription_access.cancelled_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'subscription_cancelled_via_webhook',
|
||||
extra={
|
||||
'stripe_subscription_id': subscription_id,
|
||||
'user_id': subscription_access.user_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
},
|
||||
)
|
||||
elif event_type == 'customer.subscription.deleted':
|
||||
subscription = event['data']['object']
|
||||
subscription_id = subscription['id']
|
||||
|
||||
with session_maker() as session:
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.stripe_subscription_id == subscription_id)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription_access:
|
||||
subscription_access.status = 'DISABLED'
|
||||
subscription_access.updated_at = datetime.now(UTC)
|
||||
session.merge(subscription_access)
|
||||
session.commit()
|
||||
|
||||
# Reset user settings to free tier defaults
|
||||
reset_user_to_free_tier_settings(subscription_access.user_id)
|
||||
|
||||
logger.info(
|
||||
'subscription_expired_reset_to_free_tier',
|
||||
extra={
|
||||
'stripe_subscription_id': subscription_id,
|
||||
'user_id': subscription_access.user_id,
|
||||
'subscription_access_id': subscription_access.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
logger.info('stripe_webhook_unhandled_event_type', extra={'type': event_type})
|
||||
|
||||
return JSONResponse({'status': 'success'})
|
||||
|
||||
|
||||
def reset_user_to_free_tier_settings(user_id: str) -> None:
|
||||
"""Reset user settings to free tier defaults when subscription ends."""
|
||||
with session_maker() as session:
|
||||
user_settings = (
|
||||
session.query(UserSettings)
|
||||
.filter(UserSettings.keycloak_user_id == user_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if user_settings:
|
||||
user_settings.llm_model = get_default_litellm_model()
|
||||
user_settings.llm_api_key = None
|
||||
user_settings.llm_api_key_for_byor = None
|
||||
user_settings.llm_base_url = LITE_LLM_API_URL
|
||||
user_settings.max_budget_per_task = None
|
||||
user_settings.confirmation_mode = False
|
||||
user_settings.enable_solvability_analysis = False
|
||||
user_settings.security_analyzer = 'llm'
|
||||
user_settings.agent = 'CodeActAgent'
|
||||
user_settings.language = 'en'
|
||||
user_settings.enable_default_condenser = True
|
||||
user_settings.enable_sound_notifications = False
|
||||
user_settings.enable_proactive_conversation_starters = True
|
||||
user_settings.user_consents_to_analytics = False
|
||||
|
||||
session.merge(user_settings)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'user_settings_reset_to_free_tier',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'reset_timestamp': datetime.now(UTC).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _get_litellm_user(client: httpx.AsyncClient, user_id: str) -> dict:
|
||||
"""Get a user from litellm with the id matching that given.
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ def _get_user_id(conversation_id: str) -> str:
|
||||
return conversation_metadata.user_id
|
||||
|
||||
|
||||
async def _get_session_api_key(user_id: str, conversation_id: str) -> str | None:
|
||||
async def _get_session_api_key(user_id: str, conversation_id: str) -> str:
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
user_id, filter_to_sids={conversation_id}
|
||||
)
|
||||
|
||||
@@ -178,8 +178,10 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
redis = self._get_redis_client()
|
||||
key = self._get_redis_conversation_key(user_id, sid)
|
||||
starting = await redis.get(key)
|
||||
logger.debug(f'maybe_start_agent_loop starting from redis: {starting}')
|
||||
|
||||
runtime = await self._get_runtime(sid)
|
||||
logger.debug(f'maybe_start_agent_loop runtime: {runtime}')
|
||||
|
||||
nested_url = None
|
||||
session_api_key = None
|
||||
@@ -187,15 +189,20 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
event_store = EventStore(sid, self.file_store, user_id)
|
||||
if runtime:
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
logger.debug(f'maybe_start_agent_loop nested_url: {nested_url}')
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
logger.debug(f'maybe_start_agent_loop session_api_key: {session_api_key}')
|
||||
status_str = (runtime.get('status') or 'stopped').upper()
|
||||
logger.debug(f'maybe_start_agent_loop status_str: {status_str}')
|
||||
if status_str in ConversationStatus:
|
||||
status = ConversationStatus[status_str]
|
||||
if status is ConversationStatus.STOPPED and starting:
|
||||
logger.debug('maybe_start_agent_loop setting status to starting...')
|
||||
status = ConversationStatus.STARTING
|
||||
|
||||
if status is ConversationStatus.STOPPED:
|
||||
# Mark the agentloop as starting in redis
|
||||
logger.debug('maybe_start_agent_loop starting agent...')
|
||||
await redis.set(key, 1, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
|
||||
|
||||
# Start the agent loop in the background
|
||||
|
||||
@@ -7,7 +7,7 @@ from storage.base import Base
|
||||
class SubscriptionAccess(Base): # type: ignore
|
||||
"""
|
||||
Represents a user's subscription access record.
|
||||
Tracks subscription status, duration, payment information, and cancellation status.
|
||||
Tracks subscription status, duration, and payment information.
|
||||
"""
|
||||
|
||||
__tablename__ = 'subscription_access'
|
||||
@@ -27,8 +27,6 @@ class SubscriptionAccess(Base): # type: ignore
|
||||
end_at = Column(DateTime(timezone=True), nullable=True)
|
||||
amount_paid = Column(DECIMAL(19, 4), nullable=True)
|
||||
stripe_invoice_payment_id = Column(String, nullable=False)
|
||||
cancelled_at = Column(DateTime(timezone=True), nullable=True)
|
||||
stripe_subscription_id = Column(String, nullable=True, index=True)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC), # type: ignore[attr-defined]
|
||||
|
||||
@@ -276,12 +276,12 @@ class VerifyWebhookStatus:
|
||||
webhook
|
||||
)
|
||||
|
||||
gitlab_service_impl = GitLabServiceImpl(external_auth_id=user_id)
|
||||
gitlab_service = GitLabServiceImpl(external_auth_id=user_id)
|
||||
|
||||
if not isinstance(gitlab_service_impl, SaaSGitLabService):
|
||||
if not isinstance(gitlab_service, SaaSGitLabService):
|
||||
raise Exception('Only SaaSGitLabService is supported')
|
||||
# Cast needed when mypy can see OpenHands
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service_impl)
|
||||
gitlab_service = cast(type[SaaSGitLabService], gitlab_service)
|
||||
|
||||
await self.verify_conditions_are_met(
|
||||
gitlab_service=gitlab_service,
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Tests for enterprise integrations utils module."""
|
||||
|
||||
import pytest
|
||||
from integrations.utils import get_summary_for_agent_state
|
||||
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
|
||||
|
||||
class TestGetSummaryForAgentState:
|
||||
"""Test cases for get_summary_for_agent_state function."""
|
||||
|
||||
def setup_method(self):
|
||||
"""Set up test fixtures."""
|
||||
self.conversation_link = 'https://example.com/conversation/123'
|
||||
|
||||
def test_empty_observations_list(self):
|
||||
"""Test handling of empty observations list."""
|
||||
result = get_summary_for_agent_state([], self.conversation_link)
|
||||
|
||||
assert 'unknown error' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'state,expected_text,includes_link',
|
||||
[
|
||||
(AgentState.RATE_LIMITED, 'rate limited', False),
|
||||
(AgentState.AWAITING_USER_INPUT, 'waiting for your input', True),
|
||||
],
|
||||
)
|
||||
def test_handled_agent_states(self, state, expected_text, includes_link):
|
||||
"""Test handling of states with specific behavior."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent state: {state.value}', agent_state=state
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert expected_text in result.lower()
|
||||
if includes_link:
|
||||
assert self.conversation_link in result
|
||||
else:
|
||||
assert self.conversation_link not in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'state',
|
||||
[
|
||||
AgentState.FINISHED,
|
||||
AgentState.PAUSED,
|
||||
AgentState.STOPPED,
|
||||
AgentState.AWAITING_USER_CONFIRMATION,
|
||||
],
|
||||
)
|
||||
def test_unhandled_agent_states(self, state):
|
||||
"""Test handling of unhandled states (should all return unknown error)."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent state: {state.value}', agent_state=state
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'unknown error' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'error_code,expected_text',
|
||||
[
|
||||
(
|
||||
'STATUS$ERROR_LLM_AUTHENTICATION',
|
||||
'authentication with the llm provider failed',
|
||||
),
|
||||
(
|
||||
'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE',
|
||||
'llm service is temporarily unavailable',
|
||||
),
|
||||
(
|
||||
'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR',
|
||||
'llm provider encountered an internal error',
|
||||
),
|
||||
('STATUS$ERROR_LLM_OUT_OF_CREDITS', "you've run out of credits"),
|
||||
('STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION', 'content policy violation'),
|
||||
],
|
||||
)
|
||||
def test_error_state_readable_reasons(self, error_code, expected_text):
|
||||
"""Test all readable error reason mappings."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content=f'Agent encountered error: {error_code}',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason=error_code,
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'encountered an error' in result.lower()
|
||||
assert expected_text in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
def test_error_state_with_custom_reason(self):
|
||||
"""Test handling of ERROR state with a custom reason."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent encountered an error',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason='Test error message',
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
assert 'encountered an error' in result.lower()
|
||||
assert 'test error message' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
|
||||
def test_multiple_observations_uses_first(self):
|
||||
"""Test that when multiple observations are provided, only the first is used."""
|
||||
observation1 = AgentStateChangedObservation(
|
||||
content='Agent is awaiting user input',
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
)
|
||||
observation2 = AgentStateChangedObservation(
|
||||
content='Agent encountered an error',
|
||||
agent_state=AgentState.ERROR,
|
||||
reason='Should not be used',
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state(
|
||||
[observation1, observation2], self.conversation_link
|
||||
)
|
||||
|
||||
# Should handle the first observation (AWAITING_USER_INPUT), not the second (ERROR)
|
||||
assert 'waiting for your input' in result.lower()
|
||||
assert 'error' not in result.lower()
|
||||
|
||||
def test_awaiting_user_input_specific_message(self):
|
||||
"""Test that AWAITING_USER_INPUT returns the specific expected message."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent is awaiting user input',
|
||||
agent_state=AgentState.AWAITING_USER_INPUT,
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
# Test the exact message format
|
||||
assert 'waiting for your input' in result.lower()
|
||||
assert 'continue the conversation' in result.lower()
|
||||
assert self.conversation_link in result
|
||||
assert 'unknown error' not in result.lower()
|
||||
|
||||
def test_rate_limited_specific_message(self):
|
||||
"""Test that RATE_LIMITED returns the specific expected message."""
|
||||
observation = AgentStateChangedObservation(
|
||||
content='Agent was rate limited', agent_state=AgentState.RATE_LIMITED
|
||||
)
|
||||
|
||||
result = get_summary_for_agent_state([observation], self.conversation_link)
|
||||
|
||||
# Test the exact message format
|
||||
assert 'rate limited' in result.lower()
|
||||
assert 'try again later' in result.lower()
|
||||
# RATE_LIMITED doesn't include conversation link in response
|
||||
assert self.conversation_link not in result
|
||||
@@ -5,16 +5,16 @@ import pytest
|
||||
import stripe
|
||||
from fastapi import HTTPException, Request, status
|
||||
from httpx import HTTPStatusError, Response
|
||||
from integrations.stripe_service import has_payment_method
|
||||
from server.routes import billing
|
||||
from server.routes.billing import (
|
||||
CreateBillingSessionResponse,
|
||||
CreateCheckoutSessionRequest,
|
||||
GetCreditsResponse,
|
||||
cancel_callback,
|
||||
cancel_subscription,
|
||||
create_checkout_session,
|
||||
create_subscription_checkout_session,
|
||||
create_customer_setup_session,
|
||||
get_credits,
|
||||
has_payment_method,
|
||||
success_callback,
|
||||
)
|
||||
from sqlalchemy import create_engine
|
||||
@@ -362,7 +362,8 @@ async def test_cancel_callback_session_not_found():
|
||||
response = await cancel_callback('test_session_id', mock_request)
|
||||
assert response.status_code == 302
|
||||
assert (
|
||||
response.headers['location'] == 'http://test.com/settings?checkout=cancel'
|
||||
response.headers['location']
|
||||
== 'http://test.com/settings/billing?checkout=cancel'
|
||||
)
|
||||
|
||||
# Verify no database updates occurred
|
||||
@@ -388,7 +389,8 @@ async def test_cancel_callback_success():
|
||||
|
||||
assert response.status_code == 302
|
||||
assert (
|
||||
response.headers['location'] == 'http://test.com/settings?checkout=cancel'
|
||||
response.headers['location']
|
||||
== 'http://test.com/settings/billing?checkout=cancel'
|
||||
)
|
||||
|
||||
# Verify database updates
|
||||
@@ -400,312 +402,51 @@ async def test_cancel_callback_success():
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_payment_method_with_payment_method():
|
||||
"""Test has_payment_method returns True when user has a payment method."""
|
||||
with (
|
||||
patch('integrations.stripe_service.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'stripe.Customer.list_payment_methods_async',
|
||||
AsyncMock(return_value=MagicMock(data=[MagicMock()])),
|
||||
) as mock_list_payment_methods,
|
||||
):
|
||||
# Setup mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
MagicMock(stripe_customer_id='cus_test123')
|
||||
)
|
||||
|
||||
mock_has_payment_method = AsyncMock(return_value=True)
|
||||
with patch(
|
||||
'integrations.stripe_service.has_payment_method', mock_has_payment_method
|
||||
):
|
||||
result = await has_payment_method('mock_user')
|
||||
assert result is True
|
||||
mock_list_payment_methods.assert_called_once_with('cus_test123')
|
||||
mock_has_payment_method.assert_called_once_with('mock_user')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_payment_method_without_payment_method():
|
||||
"""Test has_payment_method returns False when user has no payment method."""
|
||||
with (
|
||||
patch('integrations.stripe_service.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'stripe.Customer.list_payment_methods_async',
|
||||
AsyncMock(return_value=MagicMock(data=[])),
|
||||
) as mock_list_payment_methods,
|
||||
mock_has_payment_method = AsyncMock(return_value=False)
|
||||
with patch(
|
||||
'integrations.stripe_service.has_payment_method', mock_has_payment_method
|
||||
):
|
||||
# Setup mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.first.return_value = (
|
||||
MagicMock(stripe_customer_id='cus_test123')
|
||||
)
|
||||
|
||||
mock_has_payment_method.return_value = False
|
||||
result = await has_payment_method('mock_user')
|
||||
assert result is False
|
||||
mock_list_payment_methods.assert_called_once_with('cus_test123')
|
||||
mock_has_payment_method.assert_called_once_with('mock_user')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subscription_success():
|
||||
"""Test successful subscription cancellation."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
# Mock active subscription
|
||||
mock_subscription_access = SubscriptionAccess(
|
||||
id=1,
|
||||
status='ACTIVE',
|
||||
user_id='test_user',
|
||||
start_at=datetime.now(UTC),
|
||||
end_at=datetime.now(UTC),
|
||||
amount_paid=2000,
|
||||
stripe_invoice_payment_id='pi_test',
|
||||
stripe_subscription_id='sub_test123',
|
||||
cancelled_at=None,
|
||||
async def test_create_customer_setup_session_success():
|
||||
"""Test successful creation of customer setup session."""
|
||||
mock_request = Request(
|
||||
scope={'type': 'http', 'state': {'user_id': 'mock_user'}, 'headers': []}
|
||||
)
|
||||
|
||||
# Mock Stripe subscription response
|
||||
mock_stripe_subscription = MagicMock()
|
||||
mock_stripe_subscription.cancel_at_period_end = True
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'stripe.Subscription.modify_async',
|
||||
AsyncMock(return_value=mock_stripe_subscription),
|
||||
) as mock_stripe_modify,
|
||||
):
|
||||
# Setup mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
|
||||
|
||||
# Call the function
|
||||
result = await cancel_subscription('test_user')
|
||||
|
||||
# Verify Stripe API was called
|
||||
mock_stripe_modify.assert_called_once_with(
|
||||
'sub_test123', cancel_at_period_end=True
|
||||
)
|
||||
|
||||
# Verify database was updated
|
||||
assert mock_subscription_access.cancelled_at is not None
|
||||
mock_session.merge.assert_called_once_with(mock_subscription_access)
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
# Verify response
|
||||
assert result.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subscription_no_active_subscription():
|
||||
"""Test cancellation when no active subscription exists."""
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Setup mock session with no subscription found
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
# Call the function and expect HTTPException
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cancel_subscription('test_user')
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
assert 'No active subscription found' in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subscription_missing_stripe_id():
|
||||
"""Test cancellation when subscription has no Stripe ID."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
# Mock subscription without Stripe ID
|
||||
mock_subscription_access = SubscriptionAccess(
|
||||
id=1,
|
||||
status='ACTIVE',
|
||||
user_id='test_user',
|
||||
start_at=datetime.now(UTC),
|
||||
end_at=datetime.now(UTC),
|
||||
amount_paid=2000,
|
||||
stripe_invoice_payment_id='pi_test',
|
||||
stripe_subscription_id=None, # Missing Stripe ID
|
||||
cancelled_at=None,
|
||||
mock_customer = stripe.Customer(
|
||||
id='mock-customer', metadata={'user_id': 'mock-user'}
|
||||
)
|
||||
mock_session = MagicMock()
|
||||
mock_session.url = 'https://checkout.stripe.com/test-session'
|
||||
mock_create = AsyncMock(return_value=mock_session)
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Setup mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
|
||||
|
||||
# Call the function and expect HTTPException
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cancel_subscription('test_user')
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert 'missing Stripe subscription ID' in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_subscription_stripe_error():
|
||||
"""Test cancellation when Stripe API fails."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
# Mock active subscription
|
||||
mock_subscription_access = SubscriptionAccess(
|
||||
id=1,
|
||||
status='ACTIVE',
|
||||
user_id='test_user',
|
||||
start_at=datetime.now(UTC),
|
||||
end_at=datetime.now(UTC),
|
||||
amount_paid=2000,
|
||||
stripe_invoice_payment_id='pi_test',
|
||||
stripe_subscription_id='sub_test123',
|
||||
cancelled_at=None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'stripe.Subscription.modify_async',
|
||||
AsyncMock(side_effect=stripe.StripeError('API Error')),
|
||||
),
|
||||
):
|
||||
# Setup mock session
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
|
||||
|
||||
# Call the function and expect HTTPException
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await cancel_subscription('test_user')
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert 'Failed to cancel subscription' in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_duplicate_prevention():
|
||||
"""Test that creating a subscription when user already has active subscription raises error."""
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
# Mock active subscription
|
||||
mock_subscription_access = SubscriptionAccess(
|
||||
id=1,
|
||||
status='ACTIVE',
|
||||
user_id='test_user',
|
||||
start_at=datetime.now(UTC),
|
||||
end_at=datetime.now(UTC),
|
||||
amount_paid=2000,
|
||||
stripe_invoice_payment_id='pi_test',
|
||||
stripe_subscription_id='sub_test123',
|
||||
cancelled_at=None,
|
||||
)
|
||||
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
):
|
||||
# Setup mock session to return existing active subscription
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = mock_subscription_access
|
||||
|
||||
# Call the function and expect HTTPException
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert (
|
||||
'user already has an active subscription'
|
||||
in str(exc_info.value.detail).lower()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_allows_after_cancellation():
|
||||
"""Test that creating a subscription is allowed when previous subscription was cancelled."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_session_obj = MagicMock()
|
||||
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
|
||||
mock_session_obj.id = 'test_session_id'
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'integrations.stripe_service.find_or_create_customer',
|
||||
AsyncMock(return_value='cus_test123'),
|
||||
),
|
||||
patch(
|
||||
'stripe.checkout.Session.create_async',
|
||||
AsyncMock(return_value=mock_session_obj),
|
||||
),
|
||||
patch(
|
||||
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
|
||||
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
|
||||
AsyncMock(return_value=mock_customer),
|
||||
),
|
||||
patch('stripe.checkout.Session.create_async', mock_create),
|
||||
):
|
||||
# Setup mock session - the query should return None because cancelled subscriptions are filtered out
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
|
||||
result = await create_customer_setup_session(mock_request)
|
||||
|
||||
# Should succeed
|
||||
result = await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert isinstance(result, CreateBillingSessionResponse)
|
||||
assert result.redirect_url == 'https://checkout.stripe.com/test-session'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_subscription_checkout_session_success_no_existing():
|
||||
"""Test successful subscription creation when no existing subscription."""
|
||||
mock_request = Request(scope={'type': 'http'})
|
||||
mock_request._base_url = URL('http://test.com/')
|
||||
|
||||
mock_session_obj = MagicMock()
|
||||
mock_session_obj.url = 'https://checkout.stripe.com/test-session'
|
||||
mock_session_obj.id = 'test_session_id'
|
||||
|
||||
with (
|
||||
patch('server.routes.billing.session_maker') as mock_session_maker,
|
||||
patch(
|
||||
'integrations.stripe_service.find_or_create_customer',
|
||||
AsyncMock(return_value='cus_test123'),
|
||||
),
|
||||
patch(
|
||||
'stripe.checkout.Session.create_async',
|
||||
AsyncMock(return_value=mock_session_obj),
|
||||
),
|
||||
patch(
|
||||
'server.routes.billing.SUBSCRIPTION_PRICE_DATA',
|
||||
{'MONTHLY_SUBSCRIPTION': {'unit_amount': 2000}},
|
||||
),
|
||||
):
|
||||
# Setup mock session to return no existing subscription
|
||||
mock_session = MagicMock()
|
||||
mock_session_maker.return_value.__enter__.return_value = mock_session
|
||||
mock_session.query.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
# Should succeed
|
||||
result = await create_subscription_checkout_session(
|
||||
mock_request, user_id='test_user'
|
||||
)
|
||||
|
||||
assert isinstance(result, CreateBillingSessionResponse)
|
||||
assert isinstance(result, billing.CreateBillingSessionResponse)
|
||||
assert result.redirect_url == 'https://checkout.stripe.com/test-session'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run lint
|
||||
npm run check-translation-completeness
|
||||
npx lint-staged
|
||||
|
||||
# Run backend pre-commit
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
FILE_VARIANTS_1,
|
||||
FILE_VARIANTS_2,
|
||||
@@ -10,20 +10,20 @@ import {
|
||||
* You can find the mock handlers in `frontend/src/mocks/file-service-handlers.ts`.
|
||||
*/
|
||||
|
||||
describe("ConversationService File API", () => {
|
||||
describe("OpenHands File API", () => {
|
||||
it("should get a list of files", async () => {
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id"),
|
||||
).resolves.toEqual(FILE_VARIANTS_1);
|
||||
await expect(OpenHands.getFiles("test-conversation-id")).resolves.toEqual(
|
||||
FILE_VARIANTS_1,
|
||||
);
|
||||
|
||||
await expect(
|
||||
ConversationService.getFiles("test-conversation-id-2"),
|
||||
OpenHands.getFiles("test-conversation-id-2"),
|
||||
).resolves.toEqual(FILE_VARIANTS_2);
|
||||
});
|
||||
|
||||
it("should get content of a file", async () => {
|
||||
await expect(
|
||||
ConversationService.getFile("test-conversation-id", "file1.txt"),
|
||||
OpenHands.getFile("test-conversation-id", "file1.txt"),
|
||||
).resolves.toEqual("Content of file1.txt");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { ExpandableMessage } from "#/components/features/chat/expandable-message";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual = await vi.importActual("react-i18next");
|
||||
@@ -113,7 +113,7 @@ describe("ExpandableMessage", () => {
|
||||
});
|
||||
|
||||
it("should render the out of credits message when the user is out of credits", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - We only care about the APP_MODE and FEATURE_FLAGS fields
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
|
||||
@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
describe("AnalyticsConsentFormModal", () => {
|
||||
it("should call saveUserSettings with consent", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCloseMock = vi.fn();
|
||||
const saveUserSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveUserSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
|
||||
wrapper: ({ children }) => (
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
@@ -80,7 +80,7 @@ describe("Messages", () => {
|
||||
});
|
||||
|
||||
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
|
||||
const getConversationSpy = vi.spyOn(ConversationService, "getConversation");
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
const mockConversation: Conversation = {
|
||||
conversation_id: "123",
|
||||
title: "Test Conversation",
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
describe("ConversationPanel", () => {
|
||||
@@ -85,7 +85,7 @@ describe("ConversationPanel", () => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(ConversationService, "getUserConversations").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
});
|
||||
@@ -101,10 +101,7 @@ describe("ConversationPanel", () => {
|
||||
});
|
||||
|
||||
it("should display an empty state when there are no conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [],
|
||||
next_page_id: null,
|
||||
@@ -117,10 +114,7 @@ describe("ConversationPanel", () => {
|
||||
});
|
||||
|
||||
it("should handle an error when fetching conversations", async () => {
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch conversations"),
|
||||
);
|
||||
@@ -209,17 +203,14 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const deleteUserConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
OpenHands,
|
||||
"deleteUserConversation",
|
||||
);
|
||||
deleteUserConversationSpy.mockImplementation(async (id: string) => {
|
||||
@@ -269,10 +260,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
it("should refetch data on rerenders", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: [...mockConversations],
|
||||
next_page_id: null,
|
||||
@@ -369,10 +357,7 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockRunningConversations,
|
||||
next_page_id: null,
|
||||
@@ -439,19 +424,13 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => ({
|
||||
results: mockData,
|
||||
next_page_id: null,
|
||||
}));
|
||||
|
||||
const stopConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"stopConversation",
|
||||
);
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
@@ -533,10 +512,7 @@ describe("ConversationPanel", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"getUserConversations",
|
||||
);
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue({
|
||||
results: mockMixedStatusConversations,
|
||||
next_page_id: null,
|
||||
@@ -643,10 +619,7 @@ describe("ConversationPanel", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the updateConversation API call
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
// Mock the toast function
|
||||
@@ -683,10 +656,7 @@ describe("ConversationPanel", () => {
|
||||
it("should save title when Enter key is pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
@@ -715,10 +685,7 @@ describe("ConversationPanel", () => {
|
||||
it("should trim whitespace from title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
@@ -747,10 +714,7 @@ describe("ConversationPanel", () => {
|
||||
it("should revert to original title when empty", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
@@ -776,10 +740,7 @@ describe("ConversationPanel", () => {
|
||||
it("should handle API error when updating title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockRejectedValue(new Error("API Error"));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
@@ -846,10 +807,7 @@ describe("ConversationPanel", () => {
|
||||
it("should not call API when title is unchanged", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
@@ -875,10 +833,7 @@ describe("ConversationPanel", () => {
|
||||
it("should handle special characters in title", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const updateConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"updateConversation",
|
||||
);
|
||||
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
|
||||
updateConversationSpy.mockResolvedValue(true);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { Provider } from "react-redux";
|
||||
import { setupStore } from "test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header/home-header";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { createRoutesStub } from "react-router";
|
||||
import { setupStore } from "test-utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation/new-conversation";
|
||||
import { NewConversation } from "#/components/features/home/new-conversation";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the translation function
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -54,10 +54,7 @@ const renderNewConversation = () => {
|
||||
|
||||
describe("NewConversation", () => {
|
||||
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderNewConversation();
|
||||
|
||||
|
||||
@@ -5,10 +5,7 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { setupStore } from "test-utils";
|
||||
import { Provider } from "react-redux";
|
||||
import { createRoutesStub, Outlet } from "react-router";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepoConnector } from "#/components/features/home/repo-connector";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
@@ -69,7 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
@@ -87,7 +84,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should render the available repositories in the dropdown", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -96,7 +93,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the search function that's used by the dropdown
|
||||
vi.spyOn(GitService, "searchGitRepositories").mockResolvedValue(
|
||||
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
|
||||
MOCK_RESPOSITORIES,
|
||||
);
|
||||
|
||||
@@ -124,7 +121,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should only enable the launch button if a repo is selected", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -138,16 +135,10 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
@@ -179,14 +170,14 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
@@ -196,7 +187,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -226,14 +217,14 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should not render the 'add github repos' link if github provider is not set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
@@ -243,7 +234,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -271,13 +262,13 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
|
||||
const getConfiSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
@@ -287,7 +278,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -315,10 +306,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
@@ -333,7 +321,7 @@ describe("RepoConnector", () => {
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -352,16 +340,10 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
@@ -403,13 +385,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -418,16 +397,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: 30,
|
||||
total_count: 2,
|
||||
});
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -475,7 +448,7 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should display a button to settings if the user needs to sign in with their git provider", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
|
||||
@@ -2,8 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, vi, beforeEach, it } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
// Create mock functions
|
||||
@@ -205,7 +204,7 @@ describe("RepositorySelectionForm", () => {
|
||||
];
|
||||
|
||||
// Create a spy on the API call
|
||||
const searchGitReposSpy = vi.spyOn(GitService, "searchGitRepositories");
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
|
||||
@@ -5,9 +5,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { Provider } from "react-redux";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { setupStore } from "test-utils";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TaskCard } from "#/components/features/home/tasks/task-card";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
@@ -59,10 +57,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call createConversation when clicking the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
@@ -75,7 +70,7 @@ describe("TaskCard", () => {
|
||||
describe("creating suggested task conversation", () => {
|
||||
beforeEach(() => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -85,10 +80,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard(MOCK_TASK_1);
|
||||
|
||||
@@ -114,10 +106,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(
|
||||
ConversationService,
|
||||
"createConversation",
|
||||
);
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-conversation-id",
|
||||
title: "Test Conversation",
|
||||
|
||||
@@ -7,8 +7,7 @@ import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import MicroagentManagement from "#/routes/microagent-management";
|
||||
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
@@ -232,20 +231,20 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(GitService, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
nextPage: null,
|
||||
});
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(GitService, "getRepositoryMicroagents").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
]);
|
||||
// Setup default mock for searchConversations
|
||||
vi.spyOn(ConversationService, "searchConversations").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
// Setup default mock for getRepositoryMicroagentContent
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Original microagent content for testing updates",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
@@ -1291,7 +1290,7 @@ describe("MicroagentManagement", () => {
|
||||
// Add microagent integration tests
|
||||
describe("Add microagent functionality", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
@@ -1984,7 +1983,7 @@ describe("MicroagentManagement", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [{ name: "main", commit_sha: "abc123", protected: false }],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
@@ -2315,7 +2314,7 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return empty content for this test
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
@@ -2364,7 +2363,7 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return content without triggers for this test
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Original microagent content for testing updates",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
@@ -2648,7 +2647,7 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return the expected content for this test
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Test microagent content for learn functionality",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
@@ -2708,7 +2707,7 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return empty content for this test
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
@@ -2766,7 +2765,7 @@ describe("MicroagentManagement", () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return content without triggers for this test
|
||||
vi.spyOn(GitService, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Test microagent content for learn functionality",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, test, vi } from "vitest";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import { renderWithProviders } from "../../../../test-utils";
|
||||
|
||||
// Mock the stripe checkout hook to avoid JSDOM navigation issues
|
||||
const mockMutate = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mock("#/hooks/mutation/stripe/use-create-stripe-checkout-session", () => ({
|
||||
useCreateStripeCheckoutSession: () => ({
|
||||
mutate: mockMutate,
|
||||
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PaymentForm", () => {
|
||||
const getBalanceSpy = vi.spyOn(BillingService, "getBalance");
|
||||
const createCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createCheckoutSession",
|
||||
);
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getBalanceSpy = vi.spyOn(OpenHands, "getBalance");
|
||||
const createCheckoutSessionSpy = vi.spyOn(OpenHands, "createCheckoutSession");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
|
||||
const renderPaymentForm = () => renderWithProviders(<PaymentForm />);
|
||||
const renderPaymentForm = () =>
|
||||
render(<PaymentForm />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// useBalance hook will return the balance only if the APP_MODE is "saas" and the billing feature is enabled
|
||||
@@ -44,7 +37,6 @@ describe("PaymentForm", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockMutate.mockClear();
|
||||
});
|
||||
|
||||
it("should render the users current balance", async () => {
|
||||
@@ -77,7 +69,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({ amount: 50 });
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it("should only accept integer values", async () => {
|
||||
@@ -90,7 +82,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).toHaveBeenCalledWith({ amount: 50 });
|
||||
expect(createCheckoutSessionSpy).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it("should disable the top-up button if the user enters an invalid amount", async () => {
|
||||
@@ -130,7 +122,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters an empty string", async () => {
|
||||
@@ -143,7 +135,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters a non-numeric value", async () => {
|
||||
@@ -158,7 +150,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters less than the minimum amount", async () => {
|
||||
@@ -171,7 +163,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("user enters a decimal value", async () => {
|
||||
@@ -185,175 +177,7 @@ describe("PaymentForm", () => {
|
||||
const topUpButton = screen.getByText("PAYMENT$ADD_CREDIT");
|
||||
await user.click(topUpButton);
|
||||
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cancel Subscription", () => {
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
const cancelSubscriptionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"cancelSubscription",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock active subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-12-31T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render cancel subscription button when user has active subscription", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render cancel subscription button when user has no subscription", async () => {
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.queryByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show confirmation modal when cancel subscription button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
// The message should be rendered (either with Trans component or regular text)
|
||||
const modalContent = screen.getByTestId("cancel-subscription-modal");
|
||||
expect(modalContent).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close modal when cancel button in modal is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Modal should be visible
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click cancel in modal
|
||||
const modalCancelButton = screen.getByTestId("modal-cancel-button");
|
||||
await user.click(modalCancelButton);
|
||||
|
||||
// Modal should be closed
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call cancel subscription API when confirm button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Click confirm in modal
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Should call the cancel subscription API
|
||||
expect(cancelSubscriptionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should close modal after successful cancellation", async () => {
|
||||
const user = userEvent.setup();
|
||||
cancelSubscriptionSpy.mockResolvedValue({
|
||||
status: "success",
|
||||
message: "Subscription cancelled successfully",
|
||||
});
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Wait for API call to complete and modal to close
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show next billing date for active subscription", async () => {
|
||||
// Mock active subscription with end_at as next billing date
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.getByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).toBeInTheDocument();
|
||||
// Check that it contains some date-related content (translation key or actual date)
|
||||
expect(nextBillingInfo).toHaveTextContent(
|
||||
/2025|PAYMENT.*BILLING.*DATE/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show next billing date when subscription is cancelled", async () => {
|
||||
// Mock cancelled subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: "2024-06-15T10:30:00Z",
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.queryByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).not.toBeInTheDocument();
|
||||
});
|
||||
expect(createCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import { Sidebar } from "#/components/features/sidebar/sidebar";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// These tests will now fail because the conversation panel is rendered through a portal
|
||||
// and technically not a child of the Sidebar component.
|
||||
@@ -19,7 +19,7 @@ const renderSidebar = () =>
|
||||
renderWithProviders(<RouterStub initialEntries={["/conversation/123"]} />);
|
||||
|
||||
describe("Sidebar", () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
11
frontend/__tests__/components/file-operations.test.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("File Operations Messages", () => {
|
||||
it.todo("should show success indicator for successful file read operation");
|
||||
|
||||
it.todo("should show failure indicator for failed file read operation");
|
||||
|
||||
it.todo("should show success indicator for successful file edit operation");
|
||||
|
||||
it.todo("should show failure indicator for failed file edit operation");
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("react-redux", async () => {
|
||||
@@ -48,7 +48,7 @@ describe("MicroagentsModal - Refresh Button", () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(ConversationService, "getMicroagents").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
});
|
||||
@@ -73,7 +73,7 @@ describe("MicroagentsModal - Refresh Button", () => {
|
||||
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshSpy = vi.spyOn(ConversationService, "getMicroagents");
|
||||
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-microagents");
|
||||
await user.click(refreshButton);
|
||||
|
||||
@@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { screen } from "@testing-library/react";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { SettingsForm } from "#/components/shared/modals/settings/settings-form";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
|
||||
describe("SettingsForm", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const RouteStub = createRoutesStub([
|
||||
{
|
||||
|
||||
58
frontend/__tests__/components/upload-image-input.test.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { UploadImageInput } from "#/components/features/images/upload-image-input";
|
||||
|
||||
describe("UploadImageInput", () => {
|
||||
const user = userEvent.setup();
|
||||
const onUploadMock = vi.fn();
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render an input", () => {
|
||||
render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
expect(screen.getByTestId("upload-image-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onUpload when a file is selected", async () => {
|
||||
render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
|
||||
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
|
||||
const input = screen.getByTestId("upload-image-input");
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
expect(onUploadMock).toHaveBeenNthCalledWith(1, [file]);
|
||||
});
|
||||
|
||||
it("should call onUpload when multiple files are selected", async () => {
|
||||
render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
|
||||
const files = [
|
||||
new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" }),
|
||||
new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
|
||||
];
|
||||
const input = screen.getByTestId("upload-image-input");
|
||||
|
||||
await user.upload(input, files);
|
||||
|
||||
expect(onUploadMock).toHaveBeenNthCalledWith(1, files);
|
||||
});
|
||||
|
||||
it("should render custom labels", () => {
|
||||
const { rerender } = render(<UploadImageInput onUpload={onUploadMock} />);
|
||||
expect(screen.getByTestId("default-label")).toBeInTheDocument();
|
||||
|
||||
function CustomLabel() {
|
||||
return <span>Custom label</span>;
|
||||
}
|
||||
rerender(
|
||||
<UploadImageInput onUpload={onUploadMock} label={<CustomLabel />} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Custom label")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("default-label")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
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(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const { result } = renderHook(() => useSaveSettings(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
|
||||
@@ -8,9 +8,8 @@ import {
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import MainApp from "#/routes/root-layout";
|
||||
import i18n from "#/i18n";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
|
||||
describe("frontend/routes/_oh", () => {
|
||||
@@ -63,8 +62,8 @@ describe("frontend/routes/_oh", () => {
|
||||
// FIXME: This test fails when it shouldn't be, please investigate
|
||||
it.skip("should render and capture the user's consent if oss mode", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const handleCaptureConsentSpy = vi.spyOn(
|
||||
CaptureConsent,
|
||||
"handleCaptureConsent",
|
||||
@@ -107,7 +106,7 @@ describe("frontend/routes/_oh", () => {
|
||||
});
|
||||
|
||||
it("should not render the user consent form if saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
GITHUB_CLIENT_ID: "test-id",
|
||||
@@ -185,8 +184,8 @@ describe("frontend/routes/_oh", () => {
|
||||
});
|
||||
|
||||
it("should render a you're in toast if it is a new user and in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import AppSettingsScreen from "#/routes/app-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { AvailableLanguages } from "#/i18n";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
@@ -25,7 +25,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the correct default values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
language: "no",
|
||||
@@ -65,8 +65,8 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should submit the form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
@@ -106,7 +106,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should only enable the submit button when there are changes", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
@@ -146,7 +146,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const handleCaptureConsentsSpy = vi.spyOn(
|
||||
@@ -168,7 +168,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: true,
|
||||
@@ -215,8 +215,8 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderAppSettingsScreen();
|
||||
@@ -240,8 +240,8 @@ describe("Form submission", () => {
|
||||
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
@@ -265,8 +265,8 @@ describe("Status toasts", () => {
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
@@ -6,11 +6,9 @@ import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
|
||||
@@ -110,7 +108,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the inputs if OSS mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
@@ -153,8 +151,8 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
@@ -228,7 +226,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
@@ -272,7 +270,7 @@ describe("Form submission", () => {
|
||||
it("should save the GitHub token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -293,7 +291,7 @@ describe("Form submission", () => {
|
||||
it("should save GitLab tokens", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -314,7 +312,7 @@ describe("Form submission", () => {
|
||||
it("should save the Bitbucket token", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
saveProvidersSpy.mockImplementation(() => Promise.resolve(true));
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -333,7 +331,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should disable the button if there is no input", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -359,8 +357,8 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should enable a disconnect tokens button if there is at least one token set", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
@@ -393,9 +391,9 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should call logout when pressing the disconnect tokens button", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const logoutSpy = vi.spyOn(AuthService, "logout");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const logoutSpy = vi.spyOn(OpenHands, "logout");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
@@ -420,7 +418,7 @@ describe("Form submission", () => {
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -444,7 +442,7 @@ describe("Form submission", () => {
|
||||
|
||||
it("should disable the button after submitting changes", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
|
||||
renderGitSettingsScreen();
|
||||
@@ -478,7 +476,7 @@ describe("Form submission", () => {
|
||||
describe("Status toasts", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
@@ -501,7 +499,7 @@ describe("Status toasts", () => {
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveProvidersSpy = vi.spyOn(SecretsService, "addGitProvider");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
@@ -7,9 +7,7 @@ import { Provider } from "react-redux";
|
||||
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
|
||||
import HomeScreen from "#/routes/home";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import MainApp from "#/routes/root-layout";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
@@ -93,7 +91,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
|
||||
describe("HomeScreen", () => {
|
||||
beforeEach(() => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
@@ -141,7 +139,7 @@ describe("HomeScreen", () => {
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -150,7 +148,7 @@ describe("HomeScreen", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
@@ -185,7 +183,7 @@ describe("HomeScreen", () => {
|
||||
|
||||
it("should filter tasks when different repositories are selected", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -194,7 +192,7 @@ describe("HomeScreen", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
@@ -248,7 +246,7 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(GitService, "getRepositoryBranches").mockResolvedValue({
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({
|
||||
branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
@@ -284,7 +282,7 @@ describe("HomeScreen", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
@@ -360,8 +358,8 @@ describe("Settings 404", () => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
it("should open the settings modal if GET /settings fails with a 404", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
@@ -419,8 +417,8 @@ describe("Settings 404", () => {
|
||||
});
|
||||
|
||||
describe("Setup Payment modal", () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
|
||||
it("should only render if SaaS mode and is new user", async () => {
|
||||
// @ts-expect-error - we only need the APP_MODE for this test
|
||||
|
||||
@@ -3,27 +3,13 @@ import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
vi.mock("react-router", () => ({
|
||||
useSearchParams: () => mockUseSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock useIsAuthed hook
|
||||
const mockUseIsAuthed = vi.fn();
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
@@ -37,17 +23,6 @@ const renderLlmSettingsScreen = () =>
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
resetTestHandlersMockSettings();
|
||||
|
||||
// Default mock for useSearchParams - returns empty params
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: () => null,
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
@@ -81,7 +56,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render the existing settings values", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
@@ -109,9 +84,7 @@ describe("Content", () => {
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
const confirmation = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// Initially confirmation mode is false, so security analyzer should not be visible
|
||||
expect(confirmation).not.toBeChecked();
|
||||
@@ -212,7 +185,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render existing advanced settings correctly", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
@@ -257,7 +230,7 @@ describe("Content", () => {
|
||||
|
||||
describe("Form submission", () => {
|
||||
it("should submit the basic form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -293,7 +266,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should submit the advanced form with the correct values", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -337,9 +310,7 @@ describe("Form submission", () => {
|
||||
// select security analyzer
|
||||
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText(
|
||||
"SETTINGS$SECURITY_ANALYZER_NONE",
|
||||
);
|
||||
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
@@ -358,7 +329,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the basic form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
@@ -401,7 +372,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should disable the button if there are no changes in the advanced form", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
@@ -421,14 +392,10 @@ describe("Form submission", () => {
|
||||
const baseUrl = await screen.findByTestId("base-url-input");
|
||||
const apiKey = await screen.findByTestId("llm-api-key-input");
|
||||
const agent = await screen.findByTestId("agent-input");
|
||||
const condensor = await screen.findByTestId(
|
||||
"enable-memory-condenser-switch",
|
||||
);
|
||||
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
|
||||
|
||||
// Confirmation mode switch is now in basic settings, always visible
|
||||
const confirmation = await screen.findByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
|
||||
|
||||
// enter custom model
|
||||
await userEvent.type(model, "-mini");
|
||||
@@ -501,13 +468,9 @@ describe("Form submission", () => {
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// select security analyzer
|
||||
const securityAnalyzer = await screen.findByTestId(
|
||||
"security-analyzer-input",
|
||||
);
|
||||
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const securityAnalyzerOption = screen.getByText(
|
||||
"SETTINGS$SECURITY_ANALYZER_NONE",
|
||||
);
|
||||
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
await userEvent.click(securityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
|
||||
|
||||
@@ -515,13 +478,9 @@ describe("Form submission", () => {
|
||||
|
||||
// revert back to original value
|
||||
await userEvent.click(securityAnalyzer);
|
||||
const originalSecurityAnalyzerOption = screen.getByText(
|
||||
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
|
||||
);
|
||||
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
|
||||
await userEvent.click(originalSecurityAnalyzerOption);
|
||||
expect(securityAnalyzer).toHaveValue(
|
||||
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
|
||||
);
|
||||
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
|
||||
expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -553,7 +512,7 @@ describe("Form submission", () => {
|
||||
|
||||
// flaky test
|
||||
it.skip("should disable the button when submitting changes", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -580,7 +539,7 @@ describe("Form submission", () => {
|
||||
});
|
||||
|
||||
it("should clear advanced settings when saving basic settings", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
llm_model: "openai/gpt-4o",
|
||||
@@ -588,7 +547,7 @@ describe("Form submission", () => {
|
||||
llm_api_key_set: true,
|
||||
confirmation_mode: true,
|
||||
});
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
@@ -624,7 +583,7 @@ describe("Form submission", () => {
|
||||
describe("Status toasts", () => {
|
||||
describe("Basic form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
@@ -645,7 +604,7 @@ describe("Status toasts", () => {
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
@@ -667,7 +626,7 @@ describe("Status toasts", () => {
|
||||
|
||||
describe("Advanced form", () => {
|
||||
it("should call displaySuccessToast when the settings are saved", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
@@ -693,7 +652,7 @@ describe("Status toasts", () => {
|
||||
});
|
||||
|
||||
it("should call displayErrorToast when the settings fail to save", async () => {
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
|
||||
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
@@ -720,401 +679,58 @@ describe("Status toasts", () => {
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
describe("SaaS subscription", () => {
|
||||
// Common mock configurations
|
||||
const MOCK_SAAS_CONFIG = {
|
||||
APP_MODE: "saas" as const,
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_ACTIVE_SUBSCRIPTION = {
|
||||
start_at: "2024-01-01",
|
||||
end_at: "2024-12-31",
|
||||
created_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to ensure it's not called
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should have a clickable upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).not.toBeDisabled();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled or non-interactive
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
|
||||
// Values should not change
|
||||
expect(apiKeyInput).toHaveValue("");
|
||||
|
||||
// Try to submit form - should not call API
|
||||
await userEvent.click(submitButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
it("should not render the runtime settings input in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
it("should call subscription checkout API when upgrade button is clicked", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Mock the subscription checkout API call
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Click the upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
await userEvent.click(upgradeButton);
|
||||
|
||||
// Should call the subscription checkout API
|
||||
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
|
||||
it("should render the runtime settings input in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
// Mock subscription checkout API
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Mock authentication to return false (unauthenticated) from the start
|
||||
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
|
||||
|
||||
// Mock settings to return default settings even when unauthenticated
|
||||
// This is necessary because the useSettings hook is disabled when user is not authenticated
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Wait for either the settings screen or skeleton to appear
|
||||
await waitFor(() => {
|
||||
const settingsScreen = screen.queryByTestId("llm-settings-screen");
|
||||
const skeleton = screen.queryByTestId("app-settings-skeleton");
|
||||
expect(settingsScreen || skeleton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// If we get the skeleton, the test scenario isn't valid - skip the rest
|
||||
if (screen.queryByTestId("app-settings-skeleton")) {
|
||||
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
|
||||
// This is the expected behavior - unauthenticated users see a skeleton loading state
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Upgrade button should be disabled for unauthenticated users
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).toBeDisabled();
|
||||
|
||||
// Clicking disabled button should not call the API
|
||||
await userEvent.click(upgradeButton);
|
||||
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
it("should always render the runtime settings input as disabled", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
await screen.findByTestId("llm-settings-form-advanced");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show upgrade banner
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
|
||||
// Form should NOT be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to track calls
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that form elements are disabled for unsubscribed users
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(confirmationModeSwitch).not.toBeChecked();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Try to click the disabled confirmation mode switch - it should not change state
|
||||
await userEvent.click(confirmationModeSwitch);
|
||||
expect(confirmationModeSwitch).not.toBeChecked(); // Should remain unchecked
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should NOT call save settings API for unsubscribed users
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show backdrop overlay for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should show backdrop overlay
|
||||
const backdrop = screen.getByTestId("settings-backdrop");
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show backdrop overlay for subscribed users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show backdrop overlay
|
||||
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
// Mock URL search params with ?checkout=success
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "success" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=success parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify success toast is displayed with correct message
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"SUBSCRIPTION$SUCCESS",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
// Mock URL search params with ?checkout=cancel
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "cancel" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=cancel parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify error toast is displayed with correct message
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
|
||||
});
|
||||
|
||||
it("should show upgrade banner when subscription is expired or disabled", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
|
||||
// The backend only returns active subscriptions within their validity period
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const confirmationModeSwitch = screen.getByTestId(
|
||||
"enable-confirmation-mode-switch",
|
||||
);
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(confirmationModeSwitch).toBeDisabled();
|
||||
});
|
||||
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
|
||||
expect(runtimeSettingsInput).toBeInTheDocument();
|
||||
expect(runtimeSettingsInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,7 @@ import { createRoutesStub, Outlet } from "react-router";
|
||||
import SecretsSettingsScreen from "#/routes/secrets-settings";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { GetSecretsResponse } from "#/api/secrets-service.types";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
const MOCK_GET_SECRETS_RESPONSE: GetSecretsResponse["custom_secrets"] = [
|
||||
@@ -54,7 +53,7 @@ const renderSecretsSettings = () =>
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
@@ -68,8 +67,8 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should NOT render a button to connect with git if they havent already in oss", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
@@ -88,7 +87,7 @@ describe("Content", () => {
|
||||
});
|
||||
|
||||
it("should render add secret button in saas mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
@@ -477,9 +476,7 @@ describe("Secret actions", () => {
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, "My_Custom_Secret");
|
||||
@@ -563,9 +560,7 @@ describe("Secret actions", () => {
|
||||
|
||||
// make POST request
|
||||
expect(createSecretSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText("SECRETS$SECRET_ALREADY_EXISTS")).toBeInTheDocument();
|
||||
|
||||
expect(nameInput).toHaveValue(MOCK_GET_SECRETS_RESPONSE[0].name);
|
||||
expect(valueInput).toHaveValue("my-custom-secret-value");
|
||||
|
||||
@@ -3,14 +3,14 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { PaymentForm } from "#/components/features/payment/payment-form";
|
||||
import * as useSettingsModule from "#/hooks/query/use-settings";
|
||||
|
||||
// Mock the useSettings hook
|
||||
vi.mock("#/hooks/query/use-settings", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("#/hooks/query/use-settings")
|
||||
>("#/hooks/query/use-settings");
|
||||
const actual = await vi.importActual<typeof import("#/hooks/query/use-settings")>("#/hooks/query/use-settings");
|
||||
return {
|
||||
...actual,
|
||||
useSettings: vi.fn().mockReturnValue({
|
||||
@@ -24,23 +24,21 @@ vi.mock("#/hooks/query/use-settings", async () => {
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
SETTINGS$NAV_INTEGRATIONS: "Integrations",
|
||||
SETTINGS$NAV_APPLICATION: "Application",
|
||||
SETTINGS$NAV_CREDITS: "Credits",
|
||||
SETTINGS$NAV_BILLING: "Billing",
|
||||
SETTINGS$NAV_API_KEYS: "API Keys",
|
||||
SETTINGS$NAV_LLM: "LLM",
|
||||
SETTINGS$NAV_USER: "User",
|
||||
SETTINGS$NAV_SECRETS: "Secrets",
|
||||
SETTINGS$NAV_MCP: "MCP",
|
||||
SETTINGS$TITLE: "Settings",
|
||||
"SETTINGS$NAV_INTEGRATIONS": "Integrations",
|
||||
"SETTINGS$NAV_APPLICATION": "Application",
|
||||
"SETTINGS$NAV_CREDITS": "Credits",
|
||||
"SETTINGS$NAV_API_KEYS": "API Keys",
|
||||
"SETTINGS$NAV_LLM": "LLM",
|
||||
"SETTINGS$NAV_USER": "User",
|
||||
"SETTINGS$NAV_SECRETS": "Secrets",
|
||||
"SETTINGS$NAV_MCP": "MCP",
|
||||
"SETTINGS$TITLE": "Settings"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -107,16 +105,16 @@ describe("Settings Billing", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should not render the billing tab if OSS mode", async () => {
|
||||
it("should not render the credits tab if OSS mode", async () => {
|
||||
// OSS mode is set by default in beforeEach
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).queryByText("Billing");
|
||||
const credits = within(navbar).queryByText("Credits");
|
||||
expect(credits).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the billing tab if SaaS mode and billing is enabled", async () => {
|
||||
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "saas",
|
||||
@@ -136,10 +134,10 @@ describe("Settings Billing", () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
within(navbar).getByText("Billing");
|
||||
within(navbar).getByText("Credits");
|
||||
});
|
||||
|
||||
it("should render the billing settings if clicking the billing item", async () => {
|
||||
it("should render the billing settings if clicking the credits item", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
@@ -160,7 +158,7 @@ describe("Settings Billing", () => {
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
const credits = within(navbar).getByText("Billing");
|
||||
const credits = within(navbar).getByText("Credits");
|
||||
await user.click(credits);
|
||||
|
||||
const billingSection = await screen.findByTestId("billing-settings");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the i18next hook
|
||||
vi.mock("react-i18next", async () => {
|
||||
@@ -93,7 +93,7 @@ describe("Settings Screen", () => {
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits", "billing"];
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
@@ -129,15 +129,14 @@ describe("Settings Screen", () => {
|
||||
mockQueryClient.setQueryData(["config"], saasConfig);
|
||||
|
||||
const sectionsToInclude = [
|
||||
"llm", // LLM settings are now always shown in SaaS mode
|
||||
"user",
|
||||
"integrations",
|
||||
"application",
|
||||
"billing", // The nav item shows "billing" text and routes to /billing
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude: string[] = []; // No sections are excluded in SaaS mode now
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -157,7 +156,7 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
|
||||
51
frontend/__tests__/utils/browser-tab.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { browserTab } from "#/utils/browser-tab";
|
||||
|
||||
// These tests exercise the browser-tab notification flasher behavior.
|
||||
// Specifically we verify that when the document title changes externally
|
||||
// while a notification is active, the flasher updates its internal
|
||||
// baseline so it restores/toggles to the new title instead of an old one.
|
||||
|
||||
describe("browserTab notifications", () => {
|
||||
const MESSAGE = "Agent ready";
|
||||
const INITIAL = "Conversation 123 | OpenHands";
|
||||
const RENAMED = "My renamed title | OpenHands";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// reset title for each test
|
||||
document.title = INITIAL;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
browserTab.stopNotification();
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("updates baseline when title changes during an active notification and restores to the new title", () => {
|
||||
// Start flashing
|
||||
browserTab.startNotification(MESSAGE);
|
||||
|
||||
// Tick once: should switch to the message
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(document.title).toBe(MESSAGE);
|
||||
|
||||
// Simulate an external rename while flashing (e.g., user edits title)
|
||||
document.title = RENAMED;
|
||||
|
||||
// Next tick: flasher observes the external change and updates baseline
|
||||
vi.advanceTimersByTime(1000);
|
||||
// On this tick, we toggle back to the message
|
||||
expect(document.title).toBe(MESSAGE);
|
||||
|
||||
// Next tick should toggle to the updated baseline (renamed title)
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(document.title).toBe(RENAMED);
|
||||
|
||||
// Stop flashing: title should remain the updated baseline
|
||||
browserTab.stopNotification();
|
||||
expect(document.title).toBe(RENAMED);
|
||||
});
|
||||
});
|
||||
9
frontend/__tests__/utils/format-ms.test.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { formatMs } from "../../src/utils/format-ms";
|
||||
|
||||
test("formatMs", () => {
|
||||
expect(formatMs(1000)).toBe("00:01");
|
||||
expect(formatMs(1000 * 60)).toBe("01:00");
|
||||
expect(formatMs(1000 * 60 * 2.5)).toBe("02:30");
|
||||
expect(formatMs(1000 * 60 * 12)).toBe("12:00");
|
||||
});
|
||||
29
frontend/__tests__/utils/i18n-test-utils.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ReactNode } from "react";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
const mockI18n = {
|
||||
language: "ja",
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"SUGGESTIONS$TODO_APP": "ToDoリストアプリを開発する",
|
||||
"LANDING$BUILD_APP_BUTTON": "プルリクエストを表示するアプリを開発する",
|
||||
"SUGGESTIONS$HACKER_NEWS": "Hacker Newsのトップ記事を表示するbashスクリプトを作成する",
|
||||
"LANDING$TITLE": "一緒に開発を始めましょう!",
|
||||
"OPEN_IN_VSCODE": "VS Codeで開く",
|
||||
"INCREASE_TEST_COVERAGE": "テストカバレッジを向上",
|
||||
"AUTO_MERGE_PRS": "PRを自動マージ",
|
||||
"FIX_README": "READMEを修正",
|
||||
"CLEAN_DEPENDENCIES": "依存関係を整理"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
exists: () => true,
|
||||
changeLanguage: () => new Promise(() => {}),
|
||||
use: () => mockI18n,
|
||||
};
|
||||
|
||||
export function I18nTestProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<I18nextProvider i18n={mockI18n as any}>{children}</I18nextProvider>
|
||||
);
|
||||
}
|
||||
20
frontend/__tests__/utils/parse-github-url.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { parseGithubUrl } from "../../src/utils/parse-github-url";
|
||||
|
||||
test("parseGithubUrl", () => {
|
||||
expect(
|
||||
parseGithubUrl("https://github.com/alexreardon/tiny-invariant"),
|
||||
).toEqual(["alexreardon", "tiny-invariant"]);
|
||||
|
||||
expect(parseGithubUrl("https://github.com/All-Hands-AI/OpenHands")).toEqual([
|
||||
"All-Hands-AI",
|
||||
"OpenHands",
|
||||
]);
|
||||
|
||||
expect(parseGithubUrl("https://github.com/All-Hands-AI/")).toEqual([
|
||||
"All-Hands-AI",
|
||||
"",
|
||||
]);
|
||||
|
||||
expect(parseGithubUrl("https://github.com/")).toEqual([]);
|
||||
});
|
||||
@@ -77,19 +77,12 @@
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"typecheck:staged": "react-router typegen && npx tsc --noEmit --skipLibCheck",
|
||||
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{ts,tsx,js}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"src/**/*.{ts,tsx}": [
|
||||
"bash -c 'npm run typecheck:staged'"
|
||||
],
|
||||
"src/**/*": [
|
||||
"npm run check-translation-completeness"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { AuthenticateResponse, GitHubAccessTokenResponse } from "./auth.types";
|
||||
import { GetConfigResponse } from "../option-service/option.types";
|
||||
|
||||
/**
|
||||
* Authentication service for handling all authentication-related API calls
|
||||
*/
|
||||
class AuthService {
|
||||
/**
|
||||
* Authenticate with GitHub token
|
||||
* @param appMode The application mode (saas or oss)
|
||||
* @returns Response with authentication status and user info if successful
|
||||
*/
|
||||
static async authenticate(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
): Promise<boolean> {
|
||||
if (appMode === "oss") return true;
|
||||
|
||||
// Just make the request, if it succeeds (no exception thrown), return true
|
||||
await openHands.post<AuthenticateResponse>("/api/authenticate");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get GitHub access token from Keycloak callback
|
||||
* @param code Code provided by GitHub
|
||||
* @returns GitHub access token
|
||||
*/
|
||||
static async getGitHubAccessToken(
|
||||
code: string,
|
||||
): Promise<GitHubAccessTokenResponse> {
|
||||
const { data } = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/keycloak/callback",
|
||||
{
|
||||
code,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout user from the application
|
||||
* @param appMode The application mode (saas or oss)
|
||||
*/
|
||||
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
|
||||
const endpoint =
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
export default AuthService;
|
||||
@@ -1,8 +0,0 @@
|
||||
export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GitHubAccessTokenResponse {
|
||||
access_token: string;
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import {
|
||||
CancelSubscriptionResponse,
|
||||
SubscriptionAccess,
|
||||
} from "./billing.types";
|
||||
|
||||
/**
|
||||
* Billing Service API - Handles all billing-related API endpoints
|
||||
*/
|
||||
class BillingService {
|
||||
/**
|
||||
* Create a Stripe checkout session for credit purchase
|
||||
* @param amount The amount to charge in dollars
|
||||
* @returns The redirect URL for the checkout session
|
||||
*/
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
{
|
||||
amount,
|
||||
},
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer setup session for payment method management
|
||||
* @returns The redirect URL for the customer setup session
|
||||
*/
|
||||
static async createBillingSessionResponse(): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-customer-setup-session",
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's current credit balance
|
||||
* @returns The user's credit balance as a string
|
||||
*/
|
||||
static async getBalance(): Promise<string> {
|
||||
const { data } = await openHands.get<{ credits: string }>(
|
||||
"/api/billing/credits",
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's subscription access information
|
||||
* @returns The user's subscription access details or null if not available
|
||||
*/
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a subscription checkout session for subscribing to a plan
|
||||
* @returns The redirect URL for the subscription checkout session
|
||||
*/
|
||||
static async createSubscriptionCheckoutSession(): Promise<{
|
||||
redirect_url?: string;
|
||||
}> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/subscription-checkout-session",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the user's subscription
|
||||
* @returns The response indicating the result of the cancellation request
|
||||
*/
|
||||
static async cancelSubscription(): Promise<CancelSubscriptionResponse> {
|
||||
const { data } = await openHands.post<CancelSubscriptionResponse>(
|
||||
"/api/billing/cancel-subscription",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default BillingService;
|
||||
@@ -1,12 +0,0 @@
|
||||
export type SubscriptionAccess = {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
created_at: string;
|
||||
cancelled_at?: string | null;
|
||||
stripe_subscription_id?: string | null;
|
||||
};
|
||||
|
||||
export interface CancelSubscriptionResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
/**
|
||||
* Returns a URL compatible for the file service
|
||||
@@ -6,4 +6,4 @@ import ConversationService from "#/api/conversation-service/conversation-service
|
||||
* @returns URL of the conversation
|
||||
*/
|
||||
export const getConversationUrl = (conversationId: string) =>
|
||||
ConversationService.getConversationUrl(conversationId);
|
||||
OpenHands.getConversationUrl(conversationId);
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository, PaginatedBranchesResponse, Branch } from "#/types/git";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import {
|
||||
MicroagentContentResponse,
|
||||
GitChange,
|
||||
GitChangeDiff,
|
||||
} from "../open-hands.types";
|
||||
import ConversationService from "../conversation-service/conversation-service.api";
|
||||
|
||||
/**
|
||||
* Git Service API - Handles all Git-related API endpoints
|
||||
*/
|
||||
class GitService {
|
||||
/**
|
||||
* Search for Git repositories
|
||||
* @param query Search query
|
||||
* @param per_page Number of results per page
|
||||
* @param selected_provider Git provider to search in
|
||||
* @returns List of matching repositories
|
||||
*/
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
selected_provider,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve user's Git repositories
|
||||
* @param selected_provider Git provider
|
||||
* @param page Page number
|
||||
* @param per_page Number of results per page
|
||||
* @returns User's repositories with pagination info
|
||||
*/
|
||||
static async retrieveUserGitRepositories(
|
||||
selected_provider: Provider,
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const { data } = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const link =
|
||||
data.length > 0 && data[0].link_header ? data[0].link_header : "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
|
||||
return { data, nextPage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve repositories from a specific installation
|
||||
* @param selected_provider Git provider
|
||||
* @param installationIndex Current installation index
|
||||
* @param installations List of installation IDs
|
||||
* @param page Page number
|
||||
* @param per_page Number of results per page
|
||||
* @returns Installation repositories with pagination info
|
||||
*/
|
||||
static async retrieveInstallationRepositories(
|
||||
selected_provider: Provider,
|
||||
installationIndex: number,
|
||||
installations: string[],
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const installationId = installations[installationIndex];
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
installation_id: installationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const link =
|
||||
response.data.length > 0 && response.data[0].link_header
|
||||
? response.data[0].link_header
|
||||
: "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
let nextInstallation: number | null;
|
||||
if (nextPage) {
|
||||
nextInstallation = installationIndex;
|
||||
} else if (installationIndex + 1 < installations.length) {
|
||||
nextInstallation = installationIndex + 1;
|
||||
} else {
|
||||
nextInstallation = null;
|
||||
}
|
||||
return {
|
||||
data: response.data,
|
||||
nextPage,
|
||||
installationIndex: nextInstallation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get repository branches
|
||||
* @param repository Repository name
|
||||
* @param page Page number
|
||||
* @param perPage Number of results per page
|
||||
* @returns Paginated branches response
|
||||
*/
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search repository branches
|
||||
* @param repository Repository name
|
||||
* @param query Search query
|
||||
* @param perPage Number of results per page
|
||||
* @param selectedProvider Git provider
|
||||
* @returns List of matching branches
|
||||
*/
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents for a repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @returns The available microagents for the repository
|
||||
*/
|
||||
static async getRepositoryMicroagents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<RepositoryMicroagent[]> {
|
||||
const { data } = await openHands.get<RepositoryMicroagent[]>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a specific microagent from a repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @param filePath The path to the microagent file within the repository
|
||||
* @returns The microagent content and metadata
|
||||
*/
|
||||
static async getRepositoryMicroagentContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): Promise<MicroagentContentResponse> {
|
||||
const { data } = await openHands.get<MicroagentContentResponse>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents/content`,
|
||||
{
|
||||
params: { file_path: filePath },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
* @returns List of installation IDs
|
||||
*/
|
||||
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
`/api/user/installations?provider=${provider}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git changes for a conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns List of git changes
|
||||
*/
|
||||
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
|
||||
const url = `${ConversationService.getConversationUrl(conversationId)}/git/changes`;
|
||||
const { data } = await openHands.get<GitChange[]>(url, {
|
||||
headers: ConversationService.getConversationHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git change diff for a specific file
|
||||
* @param conversationId The conversation ID
|
||||
* @param path The file path
|
||||
* @returns Git change diff
|
||||
*/
|
||||
static async getGitChangeDiff(
|
||||
conversationId: string,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const url = `${ConversationService.getConversationUrl(conversationId)}/git/diff`;
|
||||
const { data } = await openHands.get<GitChangeDiff>(url, {
|
||||
params: { path },
|
||||
headers: ConversationService.getConversationHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default GitService;
|
||||
@@ -2,23 +2,38 @@ import { AxiosHeaders } from "axios";
|
||||
import {
|
||||
Feedback,
|
||||
FeedbackResponse,
|
||||
GitHubAccessTokenResponse,
|
||||
GetConfigResponse,
|
||||
GetVSCodeUrlResponse,
|
||||
AuthenticateResponse,
|
||||
Conversation,
|
||||
ResultSet,
|
||||
GetTrajectoryResponse,
|
||||
GitChangeDiff,
|
||||
GitChange,
|
||||
GetMicroagentsResponse,
|
||||
GetMicroagentPromptResponse,
|
||||
CreateMicroagent,
|
||||
MicroagentContentResponse,
|
||||
FileUploadSuccessResponse,
|
||||
GetFilesResponse,
|
||||
GetFileResponse,
|
||||
} from "../open-hands.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { Provider } from "#/types/settings";
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
Branch,
|
||||
} from "#/types/git";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
import { SubscriptionAccess } from "#/types/billing";
|
||||
|
||||
class ConversationService {
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
|
||||
/**
|
||||
@@ -51,6 +66,42 @@ class ConversationService {
|
||||
return `/api/conversations/${conversationId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of models available
|
||||
* @returns List of models available
|
||||
*/
|
||||
static async getModels(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/options/models");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of agents available
|
||||
* @returns List of agents available
|
||||
*/
|
||||
static async getAgents(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/options/agents");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of security analyzers available
|
||||
* @returns List of security analyzers available
|
||||
*/
|
||||
static async getSecurityAnalyzers(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
"/api/options/security-analyzers",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
const { data } = await openHands.get<GetConfigResponse>(
|
||||
"/api/options/config",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static getConversationHeaders(): AxiosHeaders {
|
||||
const headers = new AxiosHeaders();
|
||||
const sessionApiKey = this.currentConversation?.session_api_key;
|
||||
@@ -159,6 +210,20 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with GitHub token
|
||||
* @returns Response with authentication status and user info if successful
|
||||
*/
|
||||
static async authenticate(
|
||||
appMode: GetConfigResponse["APP_MODE"],
|
||||
): Promise<boolean> {
|
||||
if (appMode === "oss") return true;
|
||||
|
||||
// Just make the request, if it succeeds (no exception thrown), return true
|
||||
await openHands.post<AuthenticateResponse>("/api/authenticate");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the blob of the workspace zip
|
||||
* @returns Blob of the workspace zip
|
||||
@@ -184,6 +249,22 @@ class ConversationService {
|
||||
return Object.keys(response.data.hosts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param code Code provided by GitHub
|
||||
* @returns GitHub access token
|
||||
*/
|
||||
static async getGitHubAccessToken(
|
||||
code: string,
|
||||
): Promise<GitHubAccessTokenResponse> {
|
||||
const { data } = await openHands.post<GitHubAccessTokenResponse>(
|
||||
"/api/keycloak/callback",
|
||||
{
|
||||
code,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the VSCode URL
|
||||
* @returns VSCode URL
|
||||
@@ -292,11 +373,15 @@ class ConversationService {
|
||||
conversationId: string,
|
||||
providers?: Provider[],
|
||||
): Promise<Conversation | null> {
|
||||
const err = new Error("Call stack:");
|
||||
console.log("startConversation...");
|
||||
console.log(err.stack);
|
||||
const { data } = await openHands.post<Conversation | null>(
|
||||
`/api/conversations/${conversationId}/start`,
|
||||
providers ? { providers_set: providers } : {},
|
||||
);
|
||||
|
||||
console.log(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -310,6 +395,92 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the settings from the server or use the default settings if not found
|
||||
*/
|
||||
static async getSettings(): Promise<ApiSettings> {
|
||||
const { data } = await openHands.get<ApiSettings>("/api/settings");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const data = await openHands.post("/api/settings", settings);
|
||||
return data.status === 200;
|
||||
}
|
||||
|
||||
static async createCheckoutSession(amount: number): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-checkout-session",
|
||||
{
|
||||
amount,
|
||||
},
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
static async createBillingSessionResponse(): Promise<string> {
|
||||
const { data } = await openHands.post(
|
||||
"/api/billing/create-customer-setup-session",
|
||||
);
|
||||
return data.redirect_url;
|
||||
}
|
||||
|
||||
static async getBalance(): Promise<string> {
|
||||
const { data } = await openHands.get<{ credits: string }>(
|
||||
"/api/billing/credits",
|
||||
);
|
||||
return data.credits;
|
||||
}
|
||||
|
||||
static async getSubscriptionAccess(): Promise<SubscriptionAccess | null> {
|
||||
const { data } = await openHands.get<SubscriptionAccess | null>(
|
||||
"/api/billing/subscription-access",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/search/repositories",
|
||||
{
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
selected_provider,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static async getTrajectory(
|
||||
conversationId: string,
|
||||
): Promise<GetTrajectoryResponse> {
|
||||
@@ -320,6 +491,131 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout(appMode: GetConfigResponse["APP_MODE"]): Promise<void> {
|
||||
const endpoint =
|
||||
appMode === "saas" ? "/api/logout" : "/api/unset-provider-tokens";
|
||||
await openHands.post(endpoint);
|
||||
}
|
||||
|
||||
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/git/changes`;
|
||||
const { data } = await openHands.get<GitChange[]>(url, {
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getGitChangeDiff(
|
||||
conversationId: string,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const url = `${this.getConversationUrl(conversationId)}/git/diff`;
|
||||
const { data } = await openHands.get<GitChangeDiff>(url, {
|
||||
params: { path },
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
static async retrieveUserGitRepositories(
|
||||
selected_provider: Provider,
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const { data } = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const link =
|
||||
data.length > 0 && data[0].link_header ? data[0].link_header : "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
|
||||
return { data, nextPage };
|
||||
}
|
||||
|
||||
static async retrieveInstallationRepositories(
|
||||
selected_provider: Provider,
|
||||
installationIndex: number,
|
||||
installations: string[],
|
||||
page = 1,
|
||||
per_page = 30,
|
||||
) {
|
||||
const installationId = installations[installationIndex];
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/repositories",
|
||||
{
|
||||
params: {
|
||||
selected_provider,
|
||||
sort: "pushed",
|
||||
page,
|
||||
per_page,
|
||||
installation_id: installationId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const link =
|
||||
response.data.length > 0 && response.data[0].link_header
|
||||
? response.data[0].link_header
|
||||
: "";
|
||||
const nextPage = extractNextPageFromLink(link);
|
||||
let nextInstallation: number | null;
|
||||
if (nextPage) {
|
||||
nextInstallation = installationIndex;
|
||||
} else if (installationIndex + 1 < installations.length) {
|
||||
nextInstallation = installationIndex + 1;
|
||||
} else {
|
||||
nextInstallation = null;
|
||||
}
|
||||
return {
|
||||
data: response.data,
|
||||
nextPage,
|
||||
installationIndex: nextInstallation,
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents associated with a conversation
|
||||
* @param conversationId The ID of the conversation
|
||||
@@ -335,6 +631,43 @@ class ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents for a repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @returns The available microagents for the repository
|
||||
*/
|
||||
static async getRepositoryMicroagents(
|
||||
owner: string,
|
||||
repo: string,
|
||||
): Promise<RepositoryMicroagent[]> {
|
||||
const { data } = await openHands.get<RepositoryMicroagent[]>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a specific microagent from a repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @param filePath The path to the microagent file within the repository
|
||||
* @returns The microagent content and metadata
|
||||
*/
|
||||
static async getRepositoryMicroagentContent(
|
||||
owner: string,
|
||||
repo: string,
|
||||
filePath: string,
|
||||
): Promise<MicroagentContentResponse> {
|
||||
const { data } = await openHands.get<MicroagentContentResponse>(
|
||||
`/api/user/repository/${owner}/${repo}/microagents/content`,
|
||||
{
|
||||
params: { file_path: filePath },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
@@ -422,6 +755,39 @@ class ConversationService {
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user installation IDs
|
||||
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
|
||||
* @returns List of installation IDs
|
||||
*/
|
||||
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
`/api/user/installations?provider=${provider}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default ConversationService;
|
||||
export default OpenHands;
|
||||
@@ -26,6 +26,10 @@ export interface FeedbackResponse {
|
||||
body: FeedbackBodyResponse;
|
||||
}
|
||||
|
||||
export interface GitHubAccessTokenResponse {
|
||||
access_token: string;
|
||||
}
|
||||
|
||||
export interface AuthenticationResponse {
|
||||
message: string;
|
||||
login?: string; // Only present when allow list is enabled
|
||||
@@ -40,6 +44,25 @@ export interface Feedback {
|
||||
trajectory: unknown[];
|
||||
}
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
};
|
||||
MAINTENANCE?: {
|
||||
startTime: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetVSCodeUrlResponse {
|
||||
vscode_url: string | null;
|
||||
error?: string;
|
||||
@@ -50,6 +73,11 @@ export interface GetTrajectoryResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface AuthenticateResponse {
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RepositorySelection {
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GetConfigResponse } from "./option.types";
|
||||
|
||||
/**
|
||||
* Service for handling API options endpoints
|
||||
*/
|
||||
class OptionService {
|
||||
/**
|
||||
* Retrieve the list of models available
|
||||
* @returns List of models available
|
||||
*/
|
||||
static async getModels(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/options/models");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of agents available
|
||||
* @returns List of agents available
|
||||
*/
|
||||
static async getAgents(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/api/options/agents");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the list of security analyzers available
|
||||
* @returns List of security analyzers available
|
||||
*/
|
||||
static async getSecurityAnalyzers(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>(
|
||||
"/api/options/security-analyzers",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configuration from the server
|
||||
* @returns Configuration response
|
||||
*/
|
||||
static async getConfig(): Promise<GetConfigResponse> {
|
||||
const { data } = await openHands.get<GetConfigResponse>(
|
||||
"/api/options/config",
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OptionService;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export interface GetConfigResponse {
|
||||
APP_MODE: "saas" | "oss";
|
||||
APP_SLUG?: string;
|
||||
GITHUB_CLIENT_ID: string;
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
};
|
||||
MAINTENANCE?: {
|
||||
startTime: string;
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import { GitUser } from "#/types/git";
|
||||
|
||||
/**
|
||||
* User Service API - Handles all user-related API endpoints
|
||||
*/
|
||||
class UserService {
|
||||
/**
|
||||
* Get the current user's Git information
|
||||
* @returns Git user information
|
||||
*/
|
||||
static async getUser(): Promise<GitUser> {
|
||||
const response = await openHands.get<GitUser>("/api/user/info");
|
||||
|
||||
const { data } = response;
|
||||
|
||||
const user: GitUser = {
|
||||
id: data.id,
|
||||
login: data.login,
|
||||
avatar_url: data.avatar_url,
|
||||
company: data.company,
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
};
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
export default UserService;
|
||||
22
frontend/src/assets/arrow.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArrowIcon;
|
||||
35
frontend/src/assets/branding/all-hands-logo-spark.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg width="70" height="46" viewBox="0 0 70 46" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_8467_33285)">
|
||||
<g clip-path="url(#clip1_8467_33285)">
|
||||
<path
|
||||
d="M66.7813 13.7968C64.5776 12.4773 63.1054 14.4995 63.286 17.2452L63.2677 17.2659C63.2738 14.3987 62.8759 11.232 61.5537 8.67021C61.0854 7.7629 60.1366 6.27147 58.2604 6.97419C57.4371 7.28256 56.6903 8.21062 57.0759 10.6064C57.0759 10.6064 57.5044 13.1208 57.4248 16.2815V16.326C56.8892 7.60872 54.8692 4.94905 51.9799 5.12103C51.0555 5.28114 49.7915 5.66956 50.2169 8.34998C50.2169 8.34998 50.6791 11.146 50.8291 13.3728L50.8382 13.4855H50.8291C49.4701 8.6791 47.6398 8.61387 46.3146 8.80067C45.1117 8.96968 43.7987 10.1854 44.4628 12.5811C46.5472 20.0976 46.1401 29.1499 45.984 30.4456C45.5586 29.5591 45.427 28.8564 44.8362 27.8838C42.4612 23.9788 41.3318 23.6912 39.9453 23.6319C38.568 23.5726 37.0805 24.3999 37.1784 25.9743C37.2794 27.5488 38.1027 27.8097 39.2719 30.0038C40.184 31.7117 40.4442 33.9503 42.2806 38.0184C43.8017 41.3867 47.7776 45.0812 55.0191 44.6424C60.8865 44.4526 69.6492 42.4541 68.125 29.3308C67.7454 27.0506 68.0301 25.1411 68.229 23.1842C68.5382 20.148 68.9911 15.1163 66.7844 13.7938L66.7813 13.7968Z"
|
||||
fill="#FFE165" />
|
||||
<path
|
||||
d="M30.1451 23.724C28.7586 23.81 27.6384 24.1154 25.3368 28.0619C24.7644 29.0433 24.6481 29.749 24.238 30.6415C24.0574 29.3487 23.479 20.3053 25.4194 12.7533C26.0377 10.3486 24.7032 9.15665 23.4973 9.0084C22.169 8.84532 20.3356 8.94317 19.0685 13.797H19.0532L19.0716 13.6576C19.1787 11.4279 19.5888 8.62591 19.5888 8.62591C19.9592 5.93659 18.6921 5.57189 17.7647 5.4266C14.8815 5.308 12.9165 7.97952 12.537 16.6197H12.5309C12.3993 13.4916 12.7758 11.0009 12.7758 11.0009C13.1155 8.59626 12.3503 7.68302 11.5209 7.38948C9.63244 6.71937 8.71117 8.22859 8.26125 9.14479C6.98801 11.7303 6.64827 14.9029 6.70949 17.7702L6.69112 17.7494C6.81661 15.0008 5.30769 13.0053 3.12849 14.3633C0.949283 15.7243 1.49715 20.7471 1.86443 23.7774C2.10316 25.7314 2.42147 27.6349 2.0848 29.921C0.811553 43.0681 9.61101 44.9094 15.4814 44.9954C22.7291 45.3067 26.6345 41.5381 28.0914 38.1431C29.8482 34.0454 30.0686 31.8008 30.947 30.0781C32.0734 27.8632 32.8936 27.5875 32.964 26.013C33.0344 24.4386 31.5316 23.638 30.1543 23.721L30.1451 23.724Z"
|
||||
fill="#FFE165" />
|
||||
<path
|
||||
d="M33.0474 23.7441C32.3129 23.0473 31.208 22.6766 30.0847 22.7419C28.285 22.8516 27.0087 23.4891 25.0468 26.6024C24.9948 23.0413 25.1998 17.6953 26.4057 12.9927C26.8587 11.2256 26.4027 10.0722 25.9375 9.41688C25.3957 8.6519 24.554 8.14783 23.6236 8.03516C22.7758 7.93139 21.6678 7.92545 20.5874 8.78532C20.5874 8.77346 20.5905 8.75864 20.5905 8.75864C20.9394 6.2324 20.0395 4.78545 17.9185 4.4593L17.8022 4.44743C16.4953 4.3911 15.3751 4.80621 14.4722 5.67794C13.9886 6.14345 13.5692 6.74536 13.205 7.48959C12.798 6.9114 12.2746 6.61786 11.8614 6.46961C9.33633 5.57119 7.94066 7.49552 7.33464 8.72306C6.63068 10.1522 6.19301 11.7534 5.94815 13.3871C5.89612 13.3545 5.84715 13.3219 5.79512 13.2922C5.23807 12.9809 4.07808 12.6014 2.57222 13.5413C0.0196132 15.1365 0.344046 19.7205 0.852119 23.8953C0.879665 24.1177 0.907211 24.3371 0.934757 24.5595C1.15207 26.2703 1.35713 27.8863 1.07555 29.7869L1.06943 29.8343C0.558293 35.1092 1.58362 39.1683 4.11787 41.9051C6.55417 44.5381 10.3708 45.9079 15.4301 45.9821C15.8005 45.9969 16.1616 46.0028 16.5136 45.9998C25.157 45.9227 28.2575 40.3039 29.0196 38.5249C30.0051 36.224 30.5162 34.5073 30.9233 33.1255C31.2386 32.0581 31.4895 31.216 31.8446 30.5163C32.2486 29.7216 32.6036 29.2057 32.9158 28.7491C33.4484 27.9722 33.9075 27.3021 33.9626 26.0568C34.0024 25.1554 33.684 24.3549 33.0382 23.7441H33.0474ZM15.9076 7.07152C16.3943 6.60304 16.9544 6.39548 17.6644 6.41031C18.2796 6.50815 18.8367 6.65344 18.5826 8.49178C18.5643 8.60742 18.1664 11.3679 18.0593 13.6154C18.0593 13.6302 18.0593 13.6451 18.0593 13.6599C17.4992 15.854 17.0309 19.0148 16.7799 23.6344C15.6934 23.6996 14.6099 23.8123 13.557 23.9605C13.2173 14.588 14.0039 8.90393 15.9045 7.07152H15.9076ZM9.17105 9.57106C9.93316 8.02923 10.5728 8.10632 11.1666 8.31684C11.9899 8.61038 11.8614 10.2026 11.7665 10.8638C11.7512 10.9706 11.3839 13.4523 11.5125 16.6101C11.4176 18.825 11.4298 21.3779 11.5431 24.2985C10.5361 24.4913 9.58118 24.7136 8.70889 24.9538C8.2957 23.6077 6.46847 15.0564 9.17105 9.57403V9.57106ZM31.2324 27.6609C30.9019 28.1413 30.4918 28.7402 30.0296 29.6475C29.5919 30.5044 29.3226 31.4236 28.9767 32.5829C28.5819 33.9142 28.0891 35.5717 27.1495 37.7688C26.4823 39.3225 23.6787 44.3691 15.4914 44.0132C10.9279 43.948 7.70192 42.8272 5.62984 40.5886C3.49349 38.2818 2.63956 34.7296 3.08948 30.0359C3.40167 27.8892 3.17212 26.0716 2.94869 24.3163C2.92114 24.0969 2.89359 23.8805 2.86605 23.661C2.62119 21.6359 1.96621 16.2543 3.67101 15.1899C4.13011 14.9022 4.50657 14.837 4.78509 14.9912C5.2595 15.2551 5.73697 16.2158 5.66963 17.7042C5.66657 17.7843 5.67575 17.8614 5.69106 17.9385C5.77675 21.4076 6.41644 24.426 6.78066 25.5587C6.18383 25.7751 5.65739 26.0005 5.21665 26.2258C4.72082 26.4808 4.53412 27.0738 4.79734 27.5542C4.98098 27.8892 5.33602 28.079 5.7033 28.076C5.85939 28.076 6.01855 28.0375 6.16852 27.9604C8.64461 26.6884 14.5181 25.3956 19.6937 25.535C20.2568 25.5439 20.719 25.1228 20.7343 24.5802C20.7496 24.0376 20.3089 23.5869 19.7488 23.5721C19.4396 23.5632 19.1274 23.5632 18.8153 23.5632C19.3325 14.247 20.7557 11.2078 21.8637 10.3094C22.3228 9.93873 22.7605 9.90908 23.3665 9.98321C23.5348 10.004 23.9572 10.0988 24.2602 10.5258C24.5877 10.9913 24.6489 11.6792 24.4347 12.5154C22.5615 19.8124 22.9839 28.3933 23.1982 30.4748C23.1614 30.5459 23.1278 30.6171 23.088 30.6912C22.6503 31.4888 21.8361 32.319 20.8659 32.2627C20.3119 32.236 19.8253 32.6452 19.7916 33.1848C19.7579 33.7274 20.1834 34.193 20.7435 34.2256C22.384 34.3205 23.9297 33.3449 24.8785 31.6163C24.9795 31.4325 25.0652 31.2575 25.1417 31.0885C25.1478 31.0767 25.1539 31.0618 25.16 31.05C25.3376 30.6645 25.4661 30.3117 25.5794 29.9944C25.7569 29.5022 25.9099 29.0753 26.216 28.5475C28.3891 24.8174 29.2705 24.7641 30.2041 24.7077C30.7519 24.6751 31.2967 24.8441 31.6181 25.1525C31.8476 25.3689 31.9486 25.6387 31.9333 25.9768C31.9027 26.6736 31.7038 26.9641 31.2233 27.6639L31.2324 27.6609Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M69.1207 29.176C68.8055 27.2813 68.9799 25.6624 69.1636 23.9485C69.188 23.7262 69.2125 23.5068 69.234 23.2844C69.6625 19.1036 69.9012 14.5107 67.3149 12.963C65.7907 12.0497 64.6368 12.45 64.0859 12.7703C64.0339 12.7999 63.9849 12.8355 63.9329 12.8681C63.6543 11.2403 63.1891 9.64804 62.4576 8.23074C61.8302 7.01506 60.4008 5.11446 57.8911 6.05735C57.4809 6.21153 56.9667 6.51397 56.5689 7.10105C56.1893 6.36275 55.7578 5.76974 55.265 5.31312C54.3468 4.45918 53.2174 4.06186 51.9136 4.14192L51.7973 4.15378C49.6823 4.51848 48.81 5.98026 49.2079 8.50649C49.2079 8.50649 49.2079 8.51835 49.211 8.52725C48.1152 7.68517 47.0073 7.71185 46.1625 7.83046C45.2352 7.96092 44.4026 8.47981 43.8762 9.25369C43.4263 9.91786 42.9886 11.0772 43.4753 12.8355C44.773 17.5173 45.0791 22.8604 45.0944 26.4214C43.0743 23.3437 41.7858 22.7299 39.9861 22.6528C38.8659 22.6054 37.761 22.9997 37.0417 23.7084C36.4081 24.331 36.1051 25.1375 36.1633 26.036C36.2429 27.2783 36.7142 27.9425 37.2621 28.7075C37.5834 29.1582 37.9477 29.6682 38.367 30.4539C38.7373 31.1477 39.0036 31.9839 39.3403 33.0454C39.7749 34.4182 40.3166 36.1261 41.3481 38.4092C42.1439 40.1734 45.3515 45.7388 53.9703 45.6587C54.3193 45.6558 54.6804 45.6439 55.0477 45.6202C60.1345 45.4571 63.9237 44.0161 66.311 41.3416C68.7902 38.5604 69.739 34.4834 69.1299 29.2175L69.1238 29.17L69.1207 29.176ZM58.0747 10.4575C57.9676 9.7874 57.8054 8.19813 58.6256 7.89272C59.2133 7.67034 59.856 7.58436 60.6457 9.11136C63.4523 14.5463 61.7904 23.1302 61.4017 24.4823C60.5233 24.2569 59.5653 24.0523 58.5552 23.8774C58.6103 20.9568 58.5736 18.4009 58.4389 16.189C58.5063 13.0312 58.0931 10.5554 58.0747 10.4575ZM52.0911 6.10182C52.8042 6.07217 53.3674 6.27083 53.8601 6.73338C55.7945 8.53318 56.6913 14.1994 56.5291 23.5779C55.4731 23.4474 54.3896 23.3555 53.3 23.3081C52.9634 18.6915 52.4339 15.5426 51.8309 13.3573C51.8309 13.3425 51.8309 13.3277 51.8309 13.3129C51.6809 11.0653 51.228 8.31376 51.2096 8.20702C50.9188 6.36572 51.4728 6.21153 52.088 6.10182H52.0911ZM67.116 29.4665C67.6546 34.1513 66.868 37.7183 64.7776 40.0637C62.7484 42.3379 59.5438 43.518 54.9528 43.6662C46.8114 44.1644 43.9038 39.1712 43.209 37.6294C42.2234 35.4471 41.7001 33.8015 41.2808 32.4761C40.9135 31.3197 40.6258 30.4094 40.1728 29.5584C39.6953 28.66 39.2729 28.07 38.9332 27.5956C38.4404 26.9047 38.2354 26.6171 38.1895 25.9203C38.168 25.5823 38.266 25.3095 38.4894 25.0901C38.8077 24.7758 39.3464 24.5949 39.8973 24.6216C40.8308 24.6631 41.7123 24.6987 43.9588 28.3902C44.2772 28.9121 44.4363 29.3361 44.623 29.8253C44.7454 30.1426 44.8801 30.4954 45.0668 30.8779C45.0729 30.8898 45.076 30.9016 45.0821 30.9105C45.1648 31.0795 45.2535 31.2515 45.3576 31.4353C46.3401 33.1462 47.9041 34.095 49.5415 33.9705C50.0986 33.929 50.5179 33.4545 50.475 32.9149C50.4322 32.3753 49.9455 31.975 49.3854 32.0106C48.4152 32.0817 47.5858 31.2663 47.1328 30.4776C47.0899 30.4035 47.0563 30.3353 47.0195 30.2641C47.194 28.1827 47.4541 19.5929 45.4402 12.3314C45.2076 11.4982 45.2566 10.8103 45.5749 10.3389C45.8718 9.906 46.2911 9.80222 46.4594 9.7785C47.0624 9.69252 47.5031 9.71624 47.9683 10.078C49.0947 10.9586 50.576 13.9711 51.2678 23.2755C50.9556 23.2784 50.6434 23.2873 50.3373 23.3022C49.7772 23.3259 49.3456 23.7855 49.3701 24.3281C49.3946 24.8707 49.8598 25.2799 50.4291 25.265C55.5986 25.0338 61.4996 26.2198 63.9971 27.4503C64.1471 27.5244 64.3063 27.557 64.4654 27.557C64.8327 27.5541 65.1847 27.3584 65.3622 27.0174C65.6162 26.5341 65.4173 25.9411 64.9153 25.695C64.4715 25.4756 63.939 25.2621 63.3391 25.0545C63.6819 23.9159 64.2665 20.8856 64.2848 17.4165C64.3001 17.3394 64.3063 17.2623 64.3001 17.1823C64.2022 15.6968 64.6644 14.7272 65.1326 14.4544C65.4081 14.2943 65.7846 14.3536 66.2498 14.6323C67.976 15.6671 67.4251 21.0576 67.217 23.0887C67.1955 23.3081 67.1711 23.5245 67.1466 23.744C66.9568 25.5052 66.7609 27.3228 67.116 29.4665Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M38.7381 10.5084C38.5759 10.5084 38.4106 10.4788 38.2545 10.4076C37.6821 10.1526 37.4312 9.49736 37.6944 8.94289C38.5453 7.1431 39.791 5.48266 41.2938 4.14245C41.7559 3.73031 42.4782 3.75699 42.9037 4.20768C43.3291 4.65541 43.3016 5.35516 42.8363 5.76731C41.5539 6.91182 40.4919 8.32912 39.7634 9.86502C39.5737 10.2653 39.1666 10.5055 38.7381 10.5084Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M34.898 9.87074C34.3073 9.87667 33.8023 9.43784 33.7533 8.85669C33.536 6.25633 33.5268 3.62039 33.7319 1.02003C33.7808 0.412188 34.3287 -0.0414663 34.9531 0.00300963C35.5805 0.0504507 36.0488 0.578232 36.0029 1.18607C35.807 3.67079 35.8162 6.1911 36.0243 8.67582C36.0763 9.28366 35.6081 9.81737 34.9806 9.86481C34.9531 9.86481 34.9255 9.86778 34.898 9.86778V9.87074Z"
|
||||
fill="black" />
|
||||
<path
|
||||
d="M30.976 10.5558C30.4649 10.5618 29.9935 10.2267 29.8619 9.7256C29.3783 7.88726 28.4632 6.14084 27.2175 4.67906C26.8165 4.20762 26.8869 3.51379 27.3705 3.12537C27.8572 2.73695 28.5734 2.80514 28.9743 3.27362C30.4312 4.98743 31.5024 7.03036 32.0656 9.18003C32.2217 9.77008 31.8514 10.372 31.2423 10.5232C31.1505 10.5469 31.0617 10.5558 30.9699 10.5588L30.976 10.5558Z"
|
||||
fill="black" />
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_8467_33285">
|
||||
<rect width="69" height="46" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
<clipPath id="clip1_8467_33285">
|
||||
<rect width="69" height="46" fill="white" transform="translate(0.5)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
22
frontend/src/assets/calendar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function Calendar() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5m-9-6h.008v.008H12v-.008ZM12 15h.008v.008H12V15Zm0 2.25h.008v.008H12v-.008ZM9.75 15h.008v.008H9.75V15Zm0 2.25h.008v.008H9.75v-.008ZM7.5 15h.008v.008H7.5V15Zm0 2.25h.008v.008H7.5v-.008Zm6.75-4.5h.008v.008h-.008v-.008Zm0 2.25h.008v.008h-.008V15Zm0 2.25h.008v.008h-.008v-.008Zm2.25-4.5h.008v.008H16.5v-.008Zm0 2.25h.008v.008H16.5V15Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
26
frontend/src/assets/cog-tooth.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
|
||||
function CogTooth() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default CogTooth;
|
||||
18
frontend/src/assets/confirm.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
function ConfirmIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfirmIcon;
|
||||
22
frontend/src/assets/earth.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function Earth() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Earth;
|
||||
BIN
frontend/src/assets/logo.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
frontend/src/assets/notification.mp3
Normal file
22
frontend/src/assets/pause.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function PauseIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 5.25v13.5m-7.5-13.5v13.5"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default PauseIcon;
|
||||
22
frontend/src/assets/pencil.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function Pencil() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Pencil;
|
||||
22
frontend/src/assets/play.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function PlayIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlayIcon;
|
||||
22
frontend/src/assets/reject.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function RejectIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default RejectIcon;
|
||||
22
frontend/src/assets/stop.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
|
||||
function StopIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-9Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default StopIcon;
|
||||
57
frontend/src/assets/vscode-alt.svg
Normal file
@@ -0,0 +1,57 @@
|
||||
<svg width="100" height="100" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0)">
|
||||
<g filter="url(#filter0_d)">
|
||||
<mask id="mask0" mask-type="alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="100" height="100">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M70.9119 99.5723C72.4869 100.189 74.2828 100.15 75.8725 99.3807L96.4604 89.4231C98.624 88.3771 100 86.1762 100 83.7616V16.2392C100 13.8247 98.624 11.6238 96.4604 10.5774L75.8725 0.619067C73.7862 -0.389991 71.3446 -0.142885 69.5135 1.19527C69.252 1.38636 69.0028 1.59985 68.769 1.83502L29.3551 37.9795L12.1872 24.88C10.5891 23.6607 8.35365 23.7606 6.86938 25.1178L1.36302 30.1525C-0.452603 31.8127 -0.454583 34.6837 1.35854 36.3466L16.2471 50.0001L1.35854 63.6536C-0.454583 65.3164 -0.452603 68.1876 1.36302 69.8477L6.86938 74.8824C8.35365 76.2395 10.5891 76.34 12.1872 75.1201L29.3551 62.0207L68.769 98.1651C69.3925 98.7923 70.1246 99.2645 70.9119 99.5723ZM75.0152 27.1813L45.1092 50.0001L75.0152 72.8189V27.1813Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0)">
|
||||
<path d="M96.4614 10.593L75.8567 0.62085C73.4717 -0.533437 70.6215 -0.0465506 68.7498 1.83492L1.29834 63.6535C-0.515935 65.3164 -0.513852 68.1875 1.30281 69.8476L6.8125 74.8823C8.29771 76.2395 10.5345 76.339 12.1335 75.1201L93.3604 13.18C96.0854 11.102 100 13.0557 100 16.4939V16.2535C100 13.84 98.6239 11.64 96.4614 10.593Z" fill="#D9D9D9"/>
|
||||
<g filter="url(#filter1_d)">
|
||||
<path d="M96.4614 89.4074L75.8567 99.3797C73.4717 100.534 70.6215 100.047 68.7498 98.1651L1.29834 36.3464C-0.515935 34.6837 -0.513852 31.8125 1.30281 30.1524L6.8125 25.1177C8.29771 23.7605 10.5345 23.6606 12.1335 24.88L93.3604 86.8201C96.0854 88.8985 100 86.9447 100 83.5061V83.747C100 86.1604 98.6239 88.3603 96.4614 89.4074Z" fill="#E6E6E6"/>
|
||||
</g>
|
||||
<g filter="url(#filter2_d)">
|
||||
<path d="M75.8578 99.3807C73.4721 100.535 70.6219 100.047 68.75 98.1651C71.0564 100.483 75 98.8415 75 95.5631V4.43709C75 1.15852 71.0565 -0.483493 68.75 1.83492C70.6219 -0.0467614 73.4721 -0.534276 75.8578 0.618963L96.4583 10.5773C98.6229 11.6237 100 13.8246 100 16.2391V83.7616C100 86.1762 98.6229 88.3761 96.4583 89.4231L75.8578 99.3807Z" fill="white"/>
|
||||
</g>
|
||||
<g style="mix-blend-mode:overlay" opacity="0.25">
|
||||
<path style="mix-blend-mode:overlay" opacity="0.25" fill-rule="evenodd" clip-rule="evenodd" d="M70.8508 99.5723C72.4258 100.189 74.2218 100.15 75.8115 99.3807L96.4 89.4231C98.5635 88.3771 99.9386 86.1762 99.9386 83.7616V16.2391C99.9386 13.8247 98.5635 11.6239 96.4 10.5774L75.8115 0.618974C73.7252 -0.390085 71.2835 -0.142871 69.4525 1.19518C69.1909 1.38637 68.9418 1.59976 68.7079 1.83493L29.2941 37.9795L12.1261 24.88C10.528 23.6606 8.2926 23.7605 6.80833 25.1177L1.30198 30.1524C-0.51354 31.8126 -0.515625 34.6837 1.2975 36.3465L16.186 50L1.2975 63.6536C-0.515625 65.3164 -0.51354 68.1875 1.30198 69.8476L6.80833 74.8824C8.2926 76.2395 10.528 76.339 12.1261 75.1201L29.2941 62.0207L68.7079 98.1651C69.3315 98.7923 70.0635 99.2645 70.8508 99.5723ZM74.9542 27.1812L45.0481 50L74.9542 72.8188V27.1812Z" fill="url(#paint0_linear)"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d" x="-6.25" y="-4.16667" width="112.5" height="112.5" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset dy="2.08333"/>
|
||||
<feGaussianBlur stdDeviation="3.125"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d" x="-8.39436" y="15.6951" width="116.728" height="92.6376" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter2_d" x="60.4167" y="-8.33346" width="47.9167" height="116.667" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="4.16667"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
|
||||
<feBlend mode="overlay" in2="BackgroundImageFix" result="effect1_dropShadow"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear" x1="49.939" y1="-5.19792e-05" x2="49.939" y2="100.001" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0">
|
||||
<rect width="100" height="100" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
20
frontend/src/components/extension-icon-map.constant.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { DiJavascript } from "react-icons/di";
|
||||
import {
|
||||
FaCss3,
|
||||
FaHtml5,
|
||||
FaList,
|
||||
FaMarkdown,
|
||||
FaNpm,
|
||||
FaPython,
|
||||
} from "react-icons/fa";
|
||||
|
||||
export const EXTENSION_ICON_MAP: Record<string, React.ReactNode> = {
|
||||
js: <DiJavascript />,
|
||||
ts: <DiJavascript />,
|
||||
py: <FaPython />,
|
||||
css: <FaCss3 />,
|
||||
json: <FaList />,
|
||||
npmignore: <FaNpm />,
|
||||
html: <FaHtml5 />,
|
||||
md: <FaMarkdown />,
|
||||
};
|
||||
@@ -25,10 +25,7 @@ import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { ErrorMessageBanner } from "./error-message-banner";
|
||||
import {
|
||||
hasUserEvent,
|
||||
shouldRenderEvent,
|
||||
} from "./event-content-helpers/should-render-event";
|
||||
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
@@ -171,14 +168,14 @@ export function ChatInterface() {
|
||||
onChatBodyScroll,
|
||||
};
|
||||
|
||||
const userEventsExist = hasUserEvent(events);
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
|
||||
{!hasSubstantiveAgentActions &&
|
||||
!optimisticUserMessage &&
|
||||
!userEventsExist && (
|
||||
!events.some(
|
||||
(event) => isOpenHandsAction(event) && event.source === "user",
|
||||
) && (
|
||||
<ChatSuggestions
|
||||
onSuggestionsClick={(message) =>
|
||||
dispatch(setMessageToSend(message))
|
||||
@@ -198,7 +195,7 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && userEventsExist && (
|
||||
{!isLoadingMessages && (
|
||||
<Messages
|
||||
messages={events}
|
||||
isAwaitingUserConfirmation={
|
||||
|
||||
@@ -45,8 +45,3 @@ export const shouldRenderEvent = (
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasUserEvent = (
|
||||
events: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) =>
|
||||
events.some((event) => isOpenHandsAction(event) && event.source === "user");
|
||||
|
||||
@@ -31,21 +31,27 @@ export function GitControlBarBranchButton({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-[200px] truncate relative",
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit max-w-none flex-shrink-0 max-w-[108px] truncate relative",
|
||||
hasBranch
|
||||
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
|
||||
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[108px]",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
|
||||
<BranchIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 truncate"
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
<div className="flex flex-row gap-2 items-center justify-start">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<BranchIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-normal text-white text-sm leading-5 truncate",
|
||||
hasBranch && "max-w-[70px]",
|
||||
)}
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBranch && <GitExternalLinkIcon />}
|
||||
</a>
|
||||
);
|
||||
|
||||
@@ -33,27 +33,32 @@ export function GitControlBarRepoButton({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] flex-1 truncate relative",
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-[170px] truncate relative",
|
||||
hasRepository
|
||||
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
|
||||
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[170px]",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center flex-shrink-0">
|
||||
{hasRepository ? (
|
||||
<GitProviderIcon
|
||||
gitProvider={gitProvider as Provider}
|
||||
className="w-3 h-3 inline-flex"
|
||||
/>
|
||||
) : (
|
||||
<RepoForkedIcon width={12} height={12} color="white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 truncate flex-1 min-w-0"
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
<div className="flex flex-row gap-2 items-center justify-start">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
{hasRepository ? (
|
||||
<GitProviderIcon
|
||||
gitProvider={gitProvider as Provider}
|
||||
className="w-3 h-3 inline-flex"
|
||||
/>
|
||||
) : (
|
||||
<RepoForkedIcon width={12} height={12} color="white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-normal text-white text-sm leading-5 truncate",
|
||||
hasRepository && "max-w-[100px]",
|
||||
)}
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
</div>
|
||||
{hasRepository && <GitExternalLinkIcon />}
|
||||
</a>
|
||||
|
||||
47
frontend/src/components/features/chat/git-scroll-button.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface GitScrollButtonProps {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function GitScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: GitScrollButtonProps) {
|
||||
const isLeft = direction === "left";
|
||||
|
||||
const baseClasses =
|
||||
"flex items-center h-[28px] w-[30.6px] min-w-[30.6px] cursor-pointer relative z-10 bg-gradient-to-l from-transparent from-[7.76%] to-[#0D0F11] to-[80.02%]";
|
||||
|
||||
const pseudoCommonElementClasses =
|
||||
"before:content-[''] before:absolute before:inset-y-0 before:w-[30px] before:pointer-events-none before:z-[5] before:backdrop-blur-[1px]";
|
||||
|
||||
const pseudoCommonGradientClasses =
|
||||
"before:from-[rgba(13,15,17,0.98)] before:from-0% before:via-[rgba(13,15,17,0.85)] before:via-[25%] before:via-[rgba(13,15,17,0.6)] before:via-[50%] before:via-[rgba(13,15,17,0.2)] before:via-[80%] before:to-transparent before:to-[100%]";
|
||||
|
||||
const pseudoElementClasses = isLeft
|
||||
? "justify-start before:right-[-30px] before:bg-gradient-to-r"
|
||||
: "justify-end before:left-[-30px] before:bg-gradient-to-l";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
pseudoCommonElementClasses,
|
||||
pseudoCommonGradientClasses,
|
||||
pseudoElementClasses,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface WaitingForRuntimeMessageProps {
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function WaitingForRuntimeMessage({
|
||||
className,
|
||||
testId,
|
||||
}: WaitingForRuntimeMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={testId}
|
||||
className={cn(
|
||||
"w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,15 +36,10 @@ const SAAS_NAV_ITEMS = [
|
||||
to: "/settings/app",
|
||||
text: "COMMON$APPLICATION_SETTINGS",
|
||||
},
|
||||
{
|
||||
icon: <CircuitIcon width={16} height={16} />,
|
||||
to: "/settings",
|
||||
text: "COMMON$LANGUAGE_MODEL_LLM",
|
||||
},
|
||||
{
|
||||
icon: <CreditCardIcon width={16} height={16} />,
|
||||
to: "/settings/billing",
|
||||
text: "SETTINGS$NAV_BILLING",
|
||||
text: "SETTINGS$NAV_CREDITS",
|
||||
},
|
||||
{
|
||||
icon: <KeyIcon width={16} height={16} />,
|
||||
@@ -56,11 +51,6 @@ const SAAS_NAV_ITEMS = [
|
||||
to: "/settings/api-keys",
|
||||
text: "SETTINGS$NAV_API_KEYS",
|
||||
},
|
||||
{
|
||||
icon: <ServerProcessIcon width={16} height={16} />,
|
||||
to: "/settings/mcp",
|
||||
text: "SETTINGS$NAV_MCP",
|
||||
},
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BaseModal } from "../../../shared/modals/base-modal/base-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
@@ -108,7 +108,7 @@ export function ConversationCard({
|
||||
// Fetch the VS Code URL from the API
|
||||
if (conversationId) {
|
||||
try {
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
const data = await OpenHands.getVSCodeUrl(conversationId);
|
||||
if (data.vscode_url) {
|
||||
const transformedUrl = transformVSCodeUrl(data.vscode_url);
|
||||
if (transformedUrl) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { Pre } from "#/ui/pre";
|
||||
|
||||
interface MicroagentContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function MicroagentContent({ content }: MicroagentContentProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
|
||||
</Typography.Text>
|
||||
<Pre
|
||||
size="default"
|
||||
font="mono"
|
||||
lineHeight="relaxed"
|
||||
background="dark"
|
||||
textColor="light"
|
||||
padding="medium"
|
||||
borderRadius="medium"
|
||||
shadow="inner"
|
||||
maxHeight="small"
|
||||
overflow="auto"
|
||||
className="mt-2"
|
||||
>
|
||||
{content || t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
|
||||
</Pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Microagent } from "#/api/open-hands.types";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { MicroagentTriggers } from "./microagent-triggers";
|
||||
import { MicroagentContent } from "./microagent-content";
|
||||
|
||||
interface MicroagentItemProps {
|
||||
agent: Microagent;
|
||||
isExpanded: boolean;
|
||||
onToggle: (agentName: string) => void;
|
||||
}
|
||||
|
||||
export function MicroagentItem({
|
||||
agent,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
}: MicroagentItemProps) {
|
||||
return (
|
||||
<div className="rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(agent.name)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="font-bold text-gray-100">
|
||||
{agent.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Typography.Text className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{agent.type === "repo" ? "Repository" : "Knowledge"}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
<MicroagentTriggers triggers={agent.triggers} />
|
||||
<MicroagentContent content={agent.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface MicroagentTriggersProps {
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export function MicroagentTriggers({ triggers }: MicroagentTriggersProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!triggers || triggers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 mb-3">
|
||||
<Typography.Text className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
|
||||
</Typography.Text>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{triggers.map((trigger) => (
|
||||
<Typography.Text
|
||||
key={trigger}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-900"
|
||||
>
|
||||
{trigger}
|
||||
</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface MicroagentsEmptyStateProps {
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function MicroagentsEmptyState({ isError }: MicroagentsEmptyStateProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<Typography.Text className="text-gray-400">
|
||||
{isError
|
||||
? t(I18nKey.MICROAGENTS_MODAL$FETCH_ERROR)
|
||||
: t(I18nKey.CONVERSATION$NO_MICROAGENTS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function MicroagentsLoadingState() {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface MicroagentsModalHeaderProps {
|
||||
isAgentReady: boolean;
|
||||
isLoading: boolean;
|
||||
isRefetching: boolean;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function MicroagentsModalHeader({
|
||||
isAgentReady,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
onRefresh,
|
||||
}: MicroagentsModalHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-microagents"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={onRefresh}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { MicroagentsModalHeader } from "./microagents-modal-header";
|
||||
import { MicroagentsLoadingState } from "./microagents-loading-state";
|
||||
import { MicroagentsEmptyState } from "./microagents-empty-state";
|
||||
import { MicroagentItem } from "./microagent-item";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
@@ -49,34 +47,57 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
className="max-h-[80vh] flex flex-col items-start"
|
||||
testID="microagents-modal"
|
||||
>
|
||||
<MicroagentsModalHeader
|
||||
isAgentReady={isAgentReady}
|
||||
isLoading={isLoading}
|
||||
isRefetching={isRefetching}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-microagents"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={refetch}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAgentReady && (
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
<span className="text-sm text-gray-400">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$WARNING)}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md custom-scrollbar-always">
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md">
|
||||
{!isAgentReady && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
<Typography.Text>
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</Typography.Text>
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <MicroagentsLoadingState />}
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !microagents || microagents.length === 0) && (
|
||||
<MicroagentsEmptyState isError={isError} />
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-gray-400">
|
||||
{isError
|
||||
? t(I18nKey.MICROAGENTS_MODAL$FETCH_ERROR)
|
||||
: t(I18nKey.CONVERSATION$NO_MICROAGENTS)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
@@ -88,12 +109,68 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const isExpanded = expandedAgents[agent.name] || false;
|
||||
|
||||
return (
|
||||
<MicroagentItem
|
||||
<div
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={toggleAgent}
|
||||
/>
|
||||
className="rounded-md overflow-hidden"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAgent(agent.name)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-bold text-gray-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{agent.type === "repo" ? "Repository" : "Knowledge"}
|
||||
</span>
|
||||
<span className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
{agent.triggers && agent.triggers.length > 0 && (
|
||||
<div className="mt-2 mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.triggers.map((trigger) => (
|
||||
<span
|
||||
key={trigger}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-900"
|
||||
>
|
||||
{trigger}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
|
||||
</h4>
|
||||
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
||||
{agent.content ||
|
||||
t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#A7A9AC"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 7h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1 0 1.43-.98 2.63-2.31 2.98l1.46 1.46C20.88 15.61 22 13.95 22 12c0-2.76-2.24-5-5-5zm-1 4h-2.19l2 2H16zM2 4.27l3.11 3.11C3.29 8.12 2 9.91 2 12c0 2.76 2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1 0-1.59 1.21-2.9 2.76-3.07L8.73 11H8v2h2.73L13 15.27V17h1.73l4.01 4L20 19.74 3.27 3 2 4.27z"/><path d="M0 24V0" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e7000b"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.04004 3.10986C12.06 3.10986 14.57 5.34986 14.98 8.25986C15.05 8.74986 15.47 9.10986 15.96 9.10986C16.57 9.10986 17.04 8.56986 16.96 7.96986C16.41 4.08986 13.07 1.10986 9.04004 1.10986C4.62004 1.10986 1.04004 4.68986 1.04004 9.10986C1.04004 13.1399 4.02004 16.4799 7.90004 17.0299C8.50004 17.1199 9.04004 16.6399 9.04004 16.0299C9.04004 15.5399 8.68004 15.1199 8.19004 15.0499C5.28004 14.6399 3.04004 12.1299 3.04004 9.10986C3.04004 5.79986 5.73004 3.10986 9.04004 3.10986Z" fill="#60BB46"/>
|
||||
<path d="M12.3504 9.11L7.40039 6.25V11.96L12.3504 9.11Z" fill="#60BB46"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 680 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2.02002C9.87012 1.46773 9.4224 1.02002 8.87012 1.02002C8.31783 1.02002 7.87012 1.46773 7.87012 2.02002V8.02002C7.87012 8.5723 8.31783 9.02002 8.87012 9.02002C9.4224 9.02002 9.87012 8.5723 9.87012 8.02002V2.02002Z" fill="#EFC818"/>
|
||||
<path d="M10.8698 2.44003V2.58003C10.8698 2.95003 11.0698 3.30003 11.4098 3.45003C13.6798 4.45003 15.2198 6.82003 14.9698 9.50003C14.6998 12.49 12.0998 14.89 9.08979 14.94C5.73979 14.99 2.98979 12.28 2.98979 8.94003C2.98979 6.59003 4.34979 4.56003 6.30979 3.58003C6.63979 3.42003 6.85979 3.10003 6.85979 2.74003V2.57003C6.85979 1.88003 6.13979 1.45003 5.50979 1.75003C2.43979 3.23003 0.449793 6.64003 1.13979 10.43C1.70979 13.59 4.23979 16.16 7.38979 16.78C12.5098 17.78 16.9998 13.88 16.9998 8.94003C16.9998 5.63003 14.9898 2.80003 12.1198 1.58003C11.5298 1.33003 10.8698 1.80003 10.8698 2.44003Z" fill="#EFC818"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 968 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.87012 2C9.87012 1.44772 9.4224 1 8.87012 1C8.31783 1 7.87012 1.44772 7.87012 2V8C7.87012 8.55228 8.31783 9 8.87012 9C9.4224 9 9.87012 8.55228 9.87012 8V2Z" fill="#A7A9AC"/>
|
||||
<path d="M10.8698 2.42001V2.56001C10.8698 2.93001 11.0698 3.28001 11.4098 3.43001C13.6798 4.43001 15.2198 6.80001 14.9698 9.48001C14.6998 12.47 12.0998 14.87 9.08979 14.92C5.73979 14.97 2.98979 12.26 2.98979 8.92001C2.98979 6.57001 4.34979 4.54001 6.30979 3.56001C6.63979 3.40001 6.85979 3.08001 6.85979 2.72001V2.55001C6.85979 1.86001 6.13979 1.43001 5.50979 1.73001C2.43979 3.20001 0.449793 6.62001 1.13979 10.41C1.70979 13.57 4.23979 16.14 7.38979 16.76C12.5098 17.76 16.9998 13.86 16.9998 8.92001C16.9998 5.61001 14.9898 2.78001 12.1198 1.56001C11.5298 1.31001 10.8698 1.78001 10.8698 2.42001Z" fill="#A7A9AC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 904 B |
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import ReactJsonView from "@microlink/react-json-view";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { SystemMessageHeader } from "./system-message-modal/system-message-header";
|
||||
import { TabNavigation } from "./system-message-modal/tab-navigation";
|
||||
import { TabContent } from "./system-message-modal/tab-content";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { JSON_VIEW_THEME } from "#/utils/constants";
|
||||
|
||||
interface SystemMessageModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,11 +19,26 @@ interface SystemMessageModalProps {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface FunctionData {
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ToolData {
|
||||
type?: string;
|
||||
function?: FunctionData;
|
||||
name?: string;
|
||||
description?: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function SystemMessageModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
systemMessage,
|
||||
}: SystemMessageModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"system" | "tools">("system");
|
||||
const [expandedTools, setExpandedTools] = useState<Record<number, boolean>>(
|
||||
{},
|
||||
@@ -44,27 +62,155 @@ export function SystemMessageModal({
|
||||
width="medium"
|
||||
className="max-h-[80vh] flex flex-col items-start"
|
||||
>
|
||||
<SystemMessageHeader
|
||||
agentClass={systemMessage.agent_class}
|
||||
openhandsVersion={systemMessage.openhands_version}
|
||||
/>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<BaseModalTitle title={t("SYSTEM_MESSAGE_MODAL$TITLE")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{systemMessage.agent_class && (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-gray-300">
|
||||
{t("SYSTEM_MESSAGE_MODAL$AGENT_CLASS")}
|
||||
</span>{" "}
|
||||
<span className="font-medium text-gray-100">
|
||||
{systemMessage.agent_class}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{systemMessage.openhands_version && (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold text-gray-300">
|
||||
{t("SYSTEM_MESSAGE_MODAL$OPENHANDS_VERSION")}
|
||||
</span>{" "}
|
||||
<span className="text-gray-100">
|
||||
{systemMessage.openhands_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
<TabNavigation
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
hasTools={
|
||||
!!(systemMessage.tools && systemMessage.tools.length > 0)
|
||||
}
|
||||
/>
|
||||
<div className="flex border-b mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 font-medium border-b-2 transition-colors",
|
||||
activeTab === "system"
|
||||
? "border-primary text-gray-100"
|
||||
: "border-transparent hover:text-gray-700 dark:hover:text-gray-300",
|
||||
)}
|
||||
onClick={() => setActiveTab("system")}
|
||||
>
|
||||
{t("SYSTEM_MESSAGE_MODAL$SYSTEM_MESSAGE_TAB")}
|
||||
</button>
|
||||
{systemMessage.tools && systemMessage.tools.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-4 py-2 font-medium border-b-2 transition-colors",
|
||||
activeTab === "tools"
|
||||
? "border-primary text-gray-100"
|
||||
: "border-transparent hover:text-gray-700 dark:hover:text-gray-300",
|
||||
)}
|
||||
onClick={() => setActiveTab("tools")}
|
||||
>
|
||||
{t("SYSTEM_MESSAGE_MODAL$TOOLS_TAB")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[51vh] overflow-auto rounded-md custom-scrollbar-always">
|
||||
<TabContent
|
||||
activeTab={activeTab}
|
||||
systemMessage={systemMessage}
|
||||
expandedTools={expandedTools}
|
||||
onToggleTool={toggleTool}
|
||||
/>
|
||||
<div className="max-h-[51vh] overflow-auto rounded-md">
|
||||
{activeTab === "system" && (
|
||||
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
|
||||
{systemMessage.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "tools" &&
|
||||
systemMessage.tools &&
|
||||
systemMessage.tools.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{systemMessage.tools.map((tool, index) => {
|
||||
// Extract function data from the nested structure
|
||||
const toolData = tool as ToolData;
|
||||
const functionData = toolData.function || toolData;
|
||||
const name =
|
||||
functionData.name ||
|
||||
(toolData.type === "function" &&
|
||||
toolData.function?.name) ||
|
||||
"";
|
||||
const description =
|
||||
functionData.description ||
|
||||
(toolData.type === "function" &&
|
||||
toolData.function?.description) ||
|
||||
"";
|
||||
const parameters =
|
||||
functionData.parameters ||
|
||||
(toolData.type === "function" &&
|
||||
toolData.function?.parameters) ||
|
||||
null;
|
||||
|
||||
const isExpanded = expandedTools[index] || false;
|
||||
|
||||
return (
|
||||
<div key={index} className="rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleTool(index)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-bold text-gray-100">
|
||||
{String(name)}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
<div className="mt-2 mb-3">
|
||||
<p className="text-sm whitespace-pre-wrap text-gray-300 leading-relaxed">
|
||||
{String(description)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Parameters section */}
|
||||
{parameters && (
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300">
|
||||
{t("SYSTEM_MESSAGE_MODAL$PARAMETERS")}
|
||||
</h4>
|
||||
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<ReactJsonView
|
||||
name={false}
|
||||
src={parameters}
|
||||
theme={JSON_VIEW_THEME}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "tools" &&
|
||||
(!systemMessage.tools || systemMessage.tools.length === 0) && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-gray-400">
|
||||
{t("SYSTEM_MESSAGE_MODAL$NO_TOOLS")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
export function EmptyToolsState() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<Typography.Text className="text-gray-400">
|
||||
{t("SYSTEM_MESSAGE_MODAL$NO_TOOLS")}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface SystemMessageContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function SystemMessageContent({ content }: SystemMessageContentProps) {
|
||||
return (
|
||||
<div className="p-4 shadow-inner">
|
||||
<Typography.CodeBlock>{content}</Typography.CodeBlock>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface SystemMessageHeaderProps {
|
||||
agentClass: string | null;
|
||||
openhandsVersion: string | null;
|
||||
}
|
||||
|
||||
export function SystemMessageHeader({
|
||||
agentClass,
|
||||
openhandsVersion,
|
||||
}: SystemMessageHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<BaseModalTitle title={t("SYSTEM_MESSAGE_MODAL$TITLE")} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{agentClass && (
|
||||
<div className="text-sm">
|
||||
<Typography.Text className="font-semibold text-gray-300">
|
||||
{t("SYSTEM_MESSAGE_MODAL$AGENT_CLASS")}
|
||||
</Typography.Text>{" "}
|
||||
<Typography.Text className="font-medium text-gray-100">
|
||||
{agentClass}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{openhandsVersion && (
|
||||
<div className="text-sm">
|
||||
<Typography.Text className="font-semibold text-gray-300">
|
||||
{t("SYSTEM_MESSAGE_MODAL$OPENHANDS_VERSION")}
|
||||
</Typography.Text>{" "}
|
||||
<Typography.Text className="text-gray-100">
|
||||
{openhandsVersion}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||