mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
132 Commits
0.51.0
...
gemini-fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91a77aee2d | ||
|
|
73a7c7786d | ||
|
|
11d12c5a01 | ||
|
|
c4f303a07b | ||
|
|
3a629cdf08 | ||
|
|
6ea33b657d | ||
|
|
a526f53181 | ||
|
|
0d28113df1 | ||
|
|
029a19ca05 | ||
|
|
d525c5ad93 | ||
|
|
881729b49c | ||
|
|
42ed36e5cc | ||
|
|
2b4e9137e3 | ||
|
|
37cebc1f8f | ||
|
|
59ecf5515e | ||
|
|
3f327a940f | ||
|
|
9c83a5623f | ||
|
|
efa3c2187d | ||
|
|
12bc965964 | ||
|
|
256bad9f5a | ||
|
|
e9700ecc3d | ||
|
|
eba4294b08 | ||
|
|
dbba60356e | ||
|
|
dceff1fae4 | ||
|
|
5a35fa571a | ||
|
|
ff2cfb7bce | ||
|
|
1c66347803 | ||
|
|
b71f258f34 | ||
|
|
e1c355c60f | ||
|
|
9d802fd7bd | ||
|
|
475ac9d29c | ||
|
|
9d6eb1c47e | ||
|
|
43d0385642 | ||
|
|
ada86336a2 | ||
|
|
8439626ada | ||
|
|
cf96ebe6f4 | ||
|
|
764226967e | ||
|
|
e4a1684197 | ||
|
|
87644aa165 | ||
|
|
255386bbfc | ||
|
|
238ae611f6 | ||
|
|
5f5a58c9bd | ||
|
|
cda29107f1 | ||
|
|
97bfa96a15 | ||
|
|
0e2f2f4173 | ||
|
|
5554b7b418 | ||
|
|
d30f77c60a | ||
|
|
a36d1673fa | ||
|
|
d233e89873 | ||
|
|
402b6224a6 | ||
|
|
4e5e2a7095 | ||
|
|
a0adbd741a | ||
|
|
d5cdecea21 | ||
|
|
fef287fcb0 | ||
|
|
6fc1a63eb8 | ||
|
|
cc64e7ba45 | ||
|
|
5364e2638b | ||
|
|
d3983b00bd | ||
|
|
39fff41dd4 | ||
|
|
d0a8c896c2 | ||
|
|
4f24bcaec9 | ||
|
|
d3209f8c28 | ||
|
|
287c34b3f3 | ||
|
|
3e1b841a99 | ||
|
|
54d3adc10f | ||
|
|
1cdc38eafb | ||
|
|
ae045251f2 | ||
|
|
9b374cd6b8 | ||
|
|
4759a78c12 | ||
|
|
d88e68eb49 | ||
|
|
b9abdf10ce | ||
|
|
5b5a9718c2 | ||
|
|
86dac5efe4 | ||
|
|
dfeab9f767 | ||
|
|
4b13658401 | ||
|
|
844b00a380 | ||
|
|
29fe911828 | ||
|
|
5282770a4c | ||
|
|
953902dcce | ||
|
|
b28e0533e0 | ||
|
|
43555fa13b | ||
|
|
10ae481b91 | ||
|
|
a2be25b261 | ||
|
|
c2e860fe92 | ||
|
|
c2fc84e6ea | ||
|
|
6f44b7352e | ||
|
|
16106e6262 | ||
|
|
6cea73b6da | ||
|
|
fdf9a49e28 | ||
|
|
e348634dbd | ||
|
|
b67db15f8a | ||
|
|
9e0731abfd | ||
|
|
b7109122da | ||
|
|
172e5d46a1 | ||
|
|
3e6768c742 | ||
|
|
cd95ce55bb | ||
|
|
c6ce36d205 | ||
|
|
1323733eee | ||
|
|
efefe72b46 | ||
|
|
606d7750fa | ||
|
|
336ddb030d | ||
|
|
1beb20746c | ||
|
|
dc99292418 | ||
|
|
9445165a23 | ||
|
|
0c254f9376 | ||
|
|
1dbcc527e7 | ||
|
|
209dc72ac5 | ||
|
|
b258c7a3b2 | ||
|
|
70968e8953 | ||
|
|
a199e1d8a2 | ||
|
|
e6d8283b6c | ||
|
|
23b0e96c67 | ||
|
|
f6b04b6f51 | ||
|
|
25ca337c7e | ||
|
|
859a43d5f4 | ||
|
|
a679468e1c | ||
|
|
5de44642eb | ||
|
|
03dea2d689 | ||
|
|
280285c0f9 | ||
|
|
8823566b51 | ||
|
|
abd199ef9e | ||
|
|
28cdb80967 | ||
|
|
3ceb0646ee | ||
|
|
c87c14a5a2 | ||
|
|
d7907ee9a9 | ||
|
|
e26e4cf09d | ||
|
|
8881f0b8af | ||
|
|
d20b3606b4 | ||
|
|
769773ad95 | ||
|
|
5d308b6df3 | ||
|
|
4e0a985f34 | ||
|
|
c6e728f76b |
49
.github/scripts/update_pr_description.sh
vendored
Executable file
49
.github/scripts/update_pr_description.sh
vendored
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
# This script updates the PR description with commands to run the PR locally
|
||||
# It adds both Docker and uvx commands
|
||||
|
||||
# Get the branch name for the PR
|
||||
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
|
||||
|
||||
# Define the Docker command
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@$BRANCH_NAME openhands"
|
||||
|
||||
# Get the current PR body
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
# Prepare the new PR body with both commands
|
||||
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
|
||||
# For existing PR descriptions, replace the command section
|
||||
NEW_PR_BODY=$(echo "$PR_BODY" | sed "s|To run this PR locally, use the following command:.*\`\`\`|To run this PR locally, use the following command:\n\nGUI with Docker:\n\`\`\`\n$DOCKER_RUN_COMMAND\n\`\`\`\n\nCLI with uvx:\n\`\`\`\n$UVX_RUN_COMMAND\n\`\`\`|s")
|
||||
else
|
||||
# For new PR descriptions
|
||||
NEW_PR_BODY="${PR_BODY}
|
||||
|
||||
---
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
|
||||
GUI with Docker:
|
||||
\`\`\`
|
||||
$DOCKER_RUN_COMMAND
|
||||
\`\`\`
|
||||
|
||||
CLI with uvx:
|
||||
\`\`\`
|
||||
$UVX_RUN_COMMAND
|
||||
\`\`\`"
|
||||
fi
|
||||
|
||||
# Update the PR description
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
gh pr edit $PR_NUMBER --body "$NEW_PR_BODY"
|
||||
28
.github/workflows/ghcr-build.yml
vendored
28
.github/workflows/ghcr-build.yml
vendored
@@ -332,29 +332,5 @@ jobs:
|
||||
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
|
||||
shell: bash
|
||||
run: |
|
||||
echo "updating PR description"
|
||||
DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
|
||||
--name openhands-app-$SHORT_SHA \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
|
||||
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
|
||||
|
||||
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
|
||||
UPDATED_PR_BODY=$(echo "${PR_BODY}" | sed -E "s|docker run -it --rm.*|$DOCKER_RUN_COMMAND|")
|
||||
else
|
||||
UPDATED_PR_BODY="${PR_BODY}
|
||||
|
||||
---
|
||||
|
||||
To run this PR locally, use the following command:
|
||||
\`\`\`
|
||||
$DOCKER_RUN_COMMAND
|
||||
\`\`\`"
|
||||
fi
|
||||
|
||||
echo "updated body: $UPDATED_PR_BODY"
|
||||
gh pr edit $PR_NUMBER --body "$UPDATED_PR_BODY"
|
||||
echo "Updating PR description with Docker and uvx commands"
|
||||
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh
|
||||
|
||||
@@ -34,7 +34,7 @@ _Dev Container: Reopen in Container_ command from the Command Palette
|
||||
|
||||
#### Develop without sudo access
|
||||
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use
|
||||
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
|
||||
`conda` or `mamba` to manage the packages for you:
|
||||
|
||||
```bash
|
||||
@@ -71,7 +71,7 @@ This command will prompt you to enter the LLM API key, model name, and other var
|
||||
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
|
||||
please set the model in the UI.
|
||||
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental
|
||||
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
|
||||
variables in your terminal. The final configurations are set from highest to lowest priority:
|
||||
Environment variables > config.toml variables > default variables
|
||||
|
||||
@@ -154,12 +154,12 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
|
||||
2. Update the poetry.lock file via `poetry lock --no-update`.
|
||||
|
||||
### 9. Use existing Docker image
|
||||
### 10. Use existing Docker image
|
||||
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.50-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.51-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -3,10 +3,10 @@ SHELL=/usr/bin/env bash
|
||||
|
||||
# Variables
|
||||
BACKEND_HOST ?= "127.0.0.1"
|
||||
BACKEND_PORT = 3000
|
||||
BACKEND_PORT ?= 3000
|
||||
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
|
||||
FRONTEND_HOST ?= "127.0.0.1"
|
||||
FRONTEND_PORT = 3001
|
||||
FRONTEND_PORT ?= 3001
|
||||
DEFAULT_WORKSPACE_DIR = "./workspace"
|
||||
DEFAULT_MODEL = "gpt-4o"
|
||||
CONFIG_FILE = config.toml
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
126
TEST_SUITE_SUMMARY.md
Normal file
126
TEST_SUITE_SUMMARY.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Consolidated Gemini Performance Test Suite
|
||||
|
||||
This document describes the consolidated and deduplicated test suite for investigating Gemini 2.5 Pro performance issues in OpenHands.
|
||||
|
||||
## 📁 Test Files Overview
|
||||
|
||||
### 1. `test_thinking_budget.py` - **PRIMARY THINKING/REASONING TEST**
|
||||
**Purpose**: Primary test for thinking budget and reasoning effort configurations
|
||||
**Features**:
|
||||
- Tests old vs new Google Generative AI APIs
|
||||
- Compares thinking budget configurations (128, 1024, 2048, 4096 tokens)
|
||||
- Tests reasoning_effort parameters via LiteLLM
|
||||
- Includes direct REST API calls for comparison
|
||||
- **User Preference**: This is the main file for thinking/reasoning tests
|
||||
|
||||
### 2. `test_litellm_comprehensive.py` - **COMPREHENSIVE LITELLM TEST**
|
||||
**Purpose**: Consolidated LiteLLM performance testing (replaces test_litellm_performance.py + test_openhands_litellm.py)
|
||||
**Features**:
|
||||
- Basic LiteLLM configurations (streaming, temperature, etc.)
|
||||
- OpenHands-style configuration and calls
|
||||
- Reasoning effort and thinking budget parameters
|
||||
- Comprehensive performance analysis and comparison
|
||||
- **Consolidation**: Combines functionality from 2 previous files
|
||||
|
||||
### 3. `test_native_gemini.py` - **NATIVE GOOGLE API TEST**
|
||||
**Purpose**: Tests native Google Generative AI library (like RooCode uses)
|
||||
**Features**:
|
||||
- Direct Google API calls without LiteLLM abstraction
|
||||
- Streaming and non-streaming tests
|
||||
- Performance comparison baseline
|
||||
- **Baseline**: Shows optimal performance without middleware
|
||||
|
||||
### 4. `test_openhands_gemini_fix.py` - **OPENHANDS FIX VERIFICATION**
|
||||
**Purpose**: Tests the actual OpenHands Gemini performance fix implementation
|
||||
**Features**:
|
||||
- Tests OpenHands with optimized thinking budget configuration
|
||||
- Verifies 2.5x speedup (from ~25s to ~10s)
|
||||
- Configuration inspection and validation
|
||||
- **Implementation**: Tests the actual fix we deployed
|
||||
|
||||
### 5. `run_performance_tests.py` - **TEST ORCHESTRATOR**
|
||||
**Purpose**: Runs all tests in sequence and provides comprehensive analysis
|
||||
**Features**:
|
||||
- Dependency checking
|
||||
- Sequential test execution
|
||||
- Performance metrics extraction
|
||||
- Comparative analysis across all test types
|
||||
- **Orchestrator**: Runs all tests and provides summary
|
||||
|
||||
## 🗑️ Removed Files (Redundant)
|
||||
|
||||
### Removed: `quick_test.py`
|
||||
- **Reason**: Very basic test, functionality covered by `test_native_gemini.py`
|
||||
- **Redundancy**: Simple native API test already in comprehensive native test
|
||||
|
||||
### Removed: `test_litellm_performance.py`
|
||||
- **Reason**: Merged into `test_litellm_comprehensive.py`
|
||||
- **Redundancy**: Basic LiteLLM configurations now in comprehensive test
|
||||
|
||||
### Removed: `test_openhands_litellm.py`
|
||||
- **Reason**: Merged into `test_litellm_comprehensive.py`
|
||||
- **Redundancy**: OpenHands-style calls now in comprehensive test
|
||||
|
||||
## 🎯 Test Suite Organization
|
||||
|
||||
```
|
||||
Performance Testing Hierarchy:
|
||||
├── run_performance_tests.py (Orchestrator)
|
||||
├── test_thinking_budget.py (Primary thinking/reasoning)
|
||||
├── test_litellm_comprehensive.py (All LiteLLM scenarios)
|
||||
├── test_native_gemini.py (Baseline performance)
|
||||
└── test_openhands_gemini_fix.py (Fix verification)
|
||||
```
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Run Individual Tests:
|
||||
```bash
|
||||
# Primary thinking/reasoning test
|
||||
python test_thinking_budget.py
|
||||
|
||||
# Comprehensive LiteLLM test
|
||||
python test_litellm_comprehensive.py
|
||||
|
||||
# Native API baseline
|
||||
python test_native_gemini.py
|
||||
|
||||
# OpenHands fix verification
|
||||
python test_openhands_gemini_fix.py
|
||||
```
|
||||
|
||||
### Run Complete Suite:
|
||||
```bash
|
||||
# Run all tests with analysis
|
||||
python run_performance_tests.py
|
||||
```
|
||||
|
||||
## 📊 Test Coverage
|
||||
|
||||
| Test Aspect | Primary Test File | Coverage |
|
||||
|-------------|------------------|----------|
|
||||
| **Thinking Budget** | `test_thinking_budget.py` | ✅ Complete |
|
||||
| **Reasoning Effort** | `test_thinking_budget.py` | ✅ Complete |
|
||||
| **LiteLLM Performance** | `test_litellm_comprehensive.py` | ✅ Complete |
|
||||
| **OpenHands Style** | `test_litellm_comprehensive.py` | ✅ Complete |
|
||||
| **Native API Baseline** | `test_native_gemini.py` | ✅ Complete |
|
||||
| **Fix Verification** | `test_openhands_gemini_fix.py` | ✅ Complete |
|
||||
| **Streaming vs Non-streaming** | All files | ✅ Complete |
|
||||
| **Parameter Variations** | All files | ✅ Complete |
|
||||
|
||||
## 🎉 Benefits of Consolidation
|
||||
|
||||
1. **Reduced Redundancy**: Eliminated duplicate test logic across 3 files
|
||||
2. **Better Organization**: Clear separation of concerns by test purpose
|
||||
3. **Easier Maintenance**: Single comprehensive test instead of multiple overlapping ones
|
||||
4. **User Preference**: `test_thinking_budget.py` as primary thinking/reasoning test
|
||||
5. **Complete Coverage**: All original functionality preserved and enhanced
|
||||
|
||||
## 🔧 Dependencies
|
||||
|
||||
- `litellm` - For LiteLLM testing
|
||||
- `google-generativeai` - For old Google API
|
||||
- `google-genai` - For new Google API with thinking budget
|
||||
- `openhands` - For OpenHands fix testing
|
||||
|
||||
All dependencies are checked by `run_performance_tests.py` before execution.
|
||||
752
comprehensive_performance_results.json
Normal file
752
comprehensive_performance_results.json
Normal file
@@ -0,0 +1,752 @@
|
||||
{
|
||||
"test_suite": "comprehensive_performance_analysis",
|
||||
"timestamp": 1753576041.7115579,
|
||||
"total_tests": 16,
|
||||
"successful_tests": 16,
|
||||
"thinking_budget_tests": {
|
||||
"test_type": "thinking_budget",
|
||||
"timestamp": 1753575753.837211,
|
||||
"total_configs": 7,
|
||||
"successful_configs": 7,
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
}
|
||||
]
|
||||
},
|
||||
"litellm_comprehensive_tests": {
|
||||
"test_type": "litellm_comprehensive",
|
||||
"timestamp": 1753575966.9497,
|
||||
"total_configs": 9,
|
||||
"successful_configs": 9,
|
||||
"results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.1620140075683594,
|
||||
"step2_duration": 6.163906097412109,
|
||||
"step3_duration": 8.57595705986023,
|
||||
"total_duration": 17.901986122131348,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 290,
|
||||
"message_count": 6,
|
||||
"config_name": "Basic LiteLLM",
|
||||
"timestamp": 1753575823.836127
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.643059253692627,
|
||||
"step2_duration": 4.244822978973389,
|
||||
"step3_duration": 8.579889059066772,
|
||||
"total_duration": 15.474514722824097,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM with Streaming",
|
||||
"timestamp": 1753575839.3106902
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.299806833267212,
|
||||
"step2_duration": 4.562235116958618,
|
||||
"step3_duration": 9.42275094985962,
|
||||
"total_duration": 17.284837007522583,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 288,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (No Stream)",
|
||||
"timestamp": 1753575856.595548
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.153742074966431,
|
||||
"step2_duration": 1.2760770320892334,
|
||||
"step3_duration": 10.748784065246582,
|
||||
"total_duration": 16.178749799728394,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Low",
|
||||
"timestamp": 1753575892.5371861
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.7451419830322266,
|
||||
"step2_duration": 1.131227970123291,
|
||||
"step3_duration": 12.550342082977295,
|
||||
"total_duration": 17.426751136779785,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: High",
|
||||
"timestamp": 1753575932.0620391
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.0755691528320312,
|
||||
"step2_duration": 3.7900118827819824,
|
||||
"step3_duration": 8.599286079406738,
|
||||
"total_duration": 15.464945077896118,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 128",
|
||||
"timestamp": 1753575947.527002
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
}
|
||||
]
|
||||
},
|
||||
"summary": {
|
||||
"all_results": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.1620140075683594,
|
||||
"step2_duration": 6.163906097412109,
|
||||
"step3_duration": 8.57595705986023,
|
||||
"total_duration": 17.901986122131348,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 290,
|
||||
"message_count": 6,
|
||||
"config_name": "Basic LiteLLM",
|
||||
"timestamp": 1753575823.836127
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.643059253692627,
|
||||
"step2_duration": 4.244822978973389,
|
||||
"step3_duration": 8.579889059066772,
|
||||
"total_duration": 15.474514722824097,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM with Streaming",
|
||||
"timestamp": 1753575839.3106902
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.299806833267212,
|
||||
"step2_duration": 4.562235116958618,
|
||||
"step3_duration": 9.42275094985962,
|
||||
"total_duration": 17.284837007522583,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 288,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (No Stream)",
|
||||
"timestamp": 1753575856.595548
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.153742074966431,
|
||||
"step2_duration": 1.2760770320892334,
|
||||
"step3_duration": 10.748784065246582,
|
||||
"total_duration": 16.178749799728394,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Low",
|
||||
"timestamp": 1753575892.5371861
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.7451419830322266,
|
||||
"step2_duration": 1.131227970123291,
|
||||
"step3_duration": 12.550342082977295,
|
||||
"total_duration": 17.426751136779785,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: High",
|
||||
"timestamp": 1753575932.0620391
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.0755691528320312,
|
||||
"step2_duration": 3.7900118827819824,
|
||||
"step3_duration": 8.599286079406738,
|
||||
"total_duration": 15.464945077896118,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 306,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 128",
|
||||
"timestamp": 1753575947.527002
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
}
|
||||
],
|
||||
"fastest_configs": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.797980308532715,
|
||||
"step2_duration": 1.8835067749023438e-05,
|
||||
"step3_duration": 2.499279260635376,
|
||||
"total_duration": 5.2979230880737305,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670.0",
|
||||
"result_correct": false,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 86,
|
||||
"message_count": 6,
|
||||
"config_name": "Old API (No Thinking)",
|
||||
"timestamp": 1753575680.1571221
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 1.8824458122253418,
|
||||
"step2_duration": 1.5384819507598877,
|
||||
"step3_duration": 2.318272113800049,
|
||||
"total_duration": 5.739390850067139,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 5,
|
||||
"step3_response_length": 160,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 128",
|
||||
"timestamp": 1753575685.896559
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.030133008956909,
|
||||
"step2_duration": 1.9902338981628418,
|
||||
"step3_duration": 2.3604180812835693,
|
||||
"total_duration": 6.380887031555176,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 277,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Thinking Budget: 128",
|
||||
"timestamp": 1753575753.83583
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.7450361251831055,
|
||||
"step2_duration": 1.0403151512145996,
|
||||
"step3_duration": 5.529464960098267,
|
||||
"total_duration": 9.314986944198608,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 36,
|
||||
"step3_response_length": 153,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 1024",
|
||||
"timestamp": 1753575695.211576
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.2801640033721924,
|
||||
"step2_duration": 1.226274013519287,
|
||||
"step3_duration": 5.528562068939209,
|
||||
"total_duration": 10.035185813903809,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 7,
|
||||
"step3_response_length": 131,
|
||||
"message_count": 6,
|
||||
"config_name": "New API - Thinking Budget: 4096",
|
||||
"timestamp": 1753575705.246801
|
||||
}
|
||||
],
|
||||
"slowest_configs": [
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.970345973968506,
|
||||
"step2_duration": 4.713220119476318,
|
||||
"step3_duration": 11.738292932510376,
|
||||
"total_duration": 19.421957969665527,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 310,
|
||||
"message_count": 6,
|
||||
"config_name": "Thinking Budget: 1024",
|
||||
"timestamp": 1753575966.948982
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 2.8680617809295654,
|
||||
"step2_duration": 4.986494064331055,
|
||||
"step3_duration": 11.908216714859009,
|
||||
"total_duration": 19.762842893600464,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 34,
|
||||
"step3_response_length": 303,
|
||||
"message_count": 6,
|
||||
"config_name": "OpenHands Style (Streaming)",
|
||||
"timestamp": 1753575876.358408
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.210190773010254,
|
||||
"step2_duration": 7.360184669494629,
|
||||
"step3_duration": 9.522583961486816,
|
||||
"total_duration": 21.093040704727173,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 283,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: Low",
|
||||
"timestamp": 1753575726.339884
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 3.9966609477996826,
|
||||
"step2_duration": 1.2283189296722412,
|
||||
"step3_duration": 15.889936923980713,
|
||||
"total_duration": 21.115014791488647,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 35,
|
||||
"step3_response_length": 0,
|
||||
"message_count": 6,
|
||||
"config_name": "LiteLLM - Reasoning Effort: High",
|
||||
"timestamp": 1753575747.454922
|
||||
},
|
||||
{
|
||||
"success": true,
|
||||
"error": null,
|
||||
"step1_duration": 4.199495792388916,
|
||||
"step2_duration": 11.224999904632568,
|
||||
"step3_duration": 6.673478841781616,
|
||||
"total_duration": 22.098058938980103,
|
||||
"tool_call_success": true,
|
||||
"tool_call_result": "5670",
|
||||
"result_correct": true,
|
||||
"step1_response_length": 0,
|
||||
"step2_response_length": 0,
|
||||
"step3_response_length": 280,
|
||||
"message_count": 6,
|
||||
"config_name": "Reasoning Effort: Medium",
|
||||
"timestamp": 1753575914.6352708
|
||||
}
|
||||
],
|
||||
"performance_analysis": {
|
||||
"fastest_time": 5.2979230880737305,
|
||||
"slowest_time": 22.098058938980103,
|
||||
"average_time": 14.999442055821419,
|
||||
"median_time": 17.284837007522583,
|
||||
"total_successful_tests": 16,
|
||||
"success_rate": 100.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,8 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
|
||||
|
||||
RUN groupadd app
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID app
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
|
||||
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.50-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.51-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -37,7 +37,16 @@
|
||||
"usage/cloud/bitbucket-installation",
|
||||
"usage/cloud/github-installation",
|
||||
"usage/cloud/gitlab-installation",
|
||||
"usage/cloud/slack-installation"
|
||||
"usage/cloud/slack-installation",
|
||||
{
|
||||
"group": "Project Management Tools",
|
||||
"pages": [
|
||||
"usage/cloud/project-management/overview",
|
||||
"usage/cloud/project-management/jira-integration",
|
||||
"usage/cloud/project-management/jira-dc-integration",
|
||||
"usage/cloud/project-management/linear-integration"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"usage/cloud/cloud-ui",
|
||||
@@ -62,6 +71,7 @@
|
||||
{
|
||||
"group": "Providers",
|
||||
"pages": [
|
||||
"usage/llms/openhands-llms",
|
||||
"usage/llms/azure-llms",
|
||||
"usage/llms/google-llms",
|
||||
"usage/llms/groq",
|
||||
@@ -69,7 +79,6 @@
|
||||
"usage/llms/litellm-proxy",
|
||||
"usage/llms/moonshot",
|
||||
"usage/llms/openai-llms",
|
||||
"usage/llms/openhands-llms",
|
||||
"usage/llms/openrouter"
|
||||
]
|
||||
}
|
||||
|
||||
BIN
docs/static/img/workspace-admin-edit.png
vendored
Normal file
BIN
docs/static/img/workspace-admin-edit.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
BIN
docs/static/img/workspace-configure.png
vendored
Normal file
BIN
docs/static/img/workspace-configure.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/static/img/workspace-link.png
vendored
Normal file
BIN
docs/static/img/workspace-link.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/static/img/workspace-user-edit.png
vendored
Normal file
BIN
docs/static/img/workspace-user-edit.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
118
docs/usage/cloud/project-management/jira-dc-integration.mdx
Normal file
118
docs/usage/cloud/project-management/jira-dc-integration.mdx
Normal file
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: Jira Data Center Integration (Beta)
|
||||
description: Complete guide for setting up Jira Data Center integration with OpenHands Cloud, including service account creation, personal access token generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
# Jira Data Center Integration
|
||||
|
||||
## Platform Configuration
|
||||
|
||||
### Step 1: Create Service Account
|
||||
|
||||
1. **Access User Management**
|
||||
- Log in to Jira Data Center as administrator
|
||||
- Go to **Administration** > **User Management**
|
||||
|
||||
2. **Create User**
|
||||
- Click **Create User**
|
||||
- Username: `openhands-agent`
|
||||
- Full Name: `OpenHands Agent`
|
||||
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
|
||||
- Password: Set a secure password
|
||||
- Click **Create**
|
||||
|
||||
3. **Assign Permissions**
|
||||
- Add user to appropriate groups
|
||||
- Ensure access to relevant projects
|
||||
- Grant necessary project permissions
|
||||
|
||||
### Step 2: Generate API Token
|
||||
|
||||
1. **Personal Access Tokens**
|
||||
- Log in as the service account
|
||||
- Go to **Profile** > **Personal Access Tokens**
|
||||
- Click **Create token**
|
||||
- Name: `OpenHands Cloud Integration`
|
||||
- Expiry: Set appropriate expiration (recommend 1 year)
|
||||
- Click **Create**
|
||||
- **Important**: Copy and store the token securely
|
||||
|
||||
### Step 3: Configure Webhook
|
||||
|
||||
1. **Create Webhook**
|
||||
- Go to **Administration** > **System** > **WebHooks**
|
||||
- Click **Create a WebHook**
|
||||
- **Name**: `OpenHands Cloud Integration`
|
||||
- **URL**: `https://app.all-hands.dev/integration/jira-dc/events`
|
||||
- Set a suitable webhook secret
|
||||
- **Issue related events**: Select the following:
|
||||
- Issue updated
|
||||
- Comment created
|
||||
- **JQL Filter**: Leave empty (or customize as needed)
|
||||
- Click **Create**
|
||||
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
|
||||
|
||||
---
|
||||
|
||||
## Workspace Integration
|
||||
|
||||
### Step 1: Log in to OpenHands Cloud
|
||||
|
||||
1. **Navigate and Authenticate**
|
||||
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
|
||||
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
|
||||
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
|
||||
|
||||
### Step 2: Configure Jira Data Center Integration
|
||||
|
||||
1. **Access Integration Settings**
|
||||
- Navigate to **Settings** > **Integrations**
|
||||
- Locate **Jira Data Center** section
|
||||
|
||||
2. **Configure Workspace**
|
||||
- Click **Configure** button
|
||||
- Enter your workspace name and click **Connect**
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The personal access token from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Jira Data Center to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
|
||||
|
||||
### Managing Your Integration
|
||||
|
||||
**Edit Configuration:**
|
||||
- Click the **Edit** button next to your configured platform
|
||||
- Update any necessary credentials or settings
|
||||
- Click **Update** to apply changes
|
||||
- You will need to repeat the OAuth flow as before
|
||||
- **Important:** Only the original user who created the integration can see the edit view
|
||||
|
||||
**Unlink Workspace:**
|
||||
- In the edit view, click **Unlink** next to the workspace name
|
||||
- This will deactivate your workspace link
|
||||
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||

|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
123
docs/usage/cloud/project-management/jira-integration.mdx
Normal file
123
docs/usage/cloud/project-management/jira-integration.mdx
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Jira Cloud Integration
|
||||
description: Complete guide for setting up Jira Cloud integration with OpenHands Cloud, including service account creation, API token generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
# Jira Cloud Integration
|
||||
|
||||
## Platform Configuration
|
||||
|
||||
### Step 1: Create Service Account
|
||||
|
||||
1. **Navigate to User Management**
|
||||
- Go to [Atlassian Admin](https://admin.atlassian.com/)
|
||||
- Select your organization
|
||||
- Go to **Directory** > **Users**
|
||||
|
||||
2. **Create OpenHands Service Account**
|
||||
- Click **Add user**
|
||||
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
|
||||
- Display name: `OpenHands Agent`
|
||||
- Send invitation: **No** (you'll set password manually)
|
||||
- Click **Add user**
|
||||
|
||||
3. **Configure Account**
|
||||
- Locate the created user and click on it
|
||||
- Set a secure password
|
||||
- Add to relevant Jira projects with appropriate permissions
|
||||
|
||||
### Step 2: Generate API Token
|
||||
|
||||
1. **Access API Token Management**
|
||||
- Log in as the OpenHands service account
|
||||
- Go to [API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
|
||||
|
||||
2. **Create API Token**
|
||||
- Click **Create API token**
|
||||
- Label: `OpenHands Cloud Integration`
|
||||
- Expiry: Set appropriate expiration (recommend 1 year)
|
||||
- Click **Create**
|
||||
- **Important**: Copy and securely store the token immediately
|
||||
|
||||
### Step 3: Configure Webhook
|
||||
|
||||
1. **Navigate to Webhook Settings**
|
||||
- Go to **Jira Settings** > **System** > **WebHooks**
|
||||
- Click **Create a WebHook**
|
||||
|
||||
2. **Configure Webhook**
|
||||
- **Name**: `OpenHands Cloud Integration`
|
||||
- **Status**: Enabled
|
||||
- **URL**: `https://app.all-hands.dev/integration/jira/events`
|
||||
- **Issue related events**: Select the following:
|
||||
- Issue updated
|
||||
- Comment created
|
||||
- **JQL Filter**: Leave empty (or customize as needed)
|
||||
- Click **Create**
|
||||
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
|
||||
|
||||
---
|
||||
|
||||
## Workspace Integration
|
||||
|
||||
### Step 1: Log in to OpenHands Cloud
|
||||
|
||||
1. **Navigate and Authenticate**
|
||||
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
|
||||
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
|
||||
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
|
||||
|
||||
### Step 2: Configure Jira Integration
|
||||
|
||||
1. **Access Integration Settings**
|
||||
- Navigate to **Settings** > **Integrations**
|
||||
- Locate **Jira Cloud** section
|
||||
|
||||
2. **Configure Workspace**
|
||||
- Click **Configure** button
|
||||
- Enter your workspace name and click **Connect**
|
||||
- **Important:** Make sure you enter the full workspace name, eg: **yourcompany.atlassian.net**
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API token from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Jira Cloud to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access.
|
||||
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
|
||||
|
||||
### Managing Your Integration
|
||||
|
||||
**Edit Configuration:**
|
||||
- Click the **Edit** button next to your configured platform
|
||||
- Update any necessary credentials or settings
|
||||
- Click **Update** to apply changes
|
||||
- You will need to repeat the OAuth flow as before
|
||||
- **Important:** Only the original user who created the integration can see the edit view
|
||||
|
||||
**Unlink Workspace:**
|
||||
- In the edit view, click **Unlink** next to the workspace name
|
||||
- This will deactivate your workspace link
|
||||
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that workspace integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||

|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
122
docs/usage/cloud/project-management/linear-integration.mdx
Normal file
122
docs/usage/cloud/project-management/linear-integration.mdx
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
title: Linear Integration
|
||||
description: Complete guide for setting up Linear integration with OpenHands Cloud, including service account creation, API key generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
# Linear Integration
|
||||
|
||||
## Platform Configuration
|
||||
|
||||
### Step 1: Create Service Account
|
||||
|
||||
1. **Access Team Settings**
|
||||
- Log in to Linear as a team admin
|
||||
- Go to **Settings** > **Members**
|
||||
|
||||
2. **Invite Service Account**
|
||||
- Click **Invite members**
|
||||
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
|
||||
- Role: **Member** (with appropriate team access)
|
||||
- Send invitation
|
||||
|
||||
3. **Complete Setup**
|
||||
- Accept invitation from the service account email
|
||||
- Complete profile setup
|
||||
- Ensure access to relevant teams/workspaces
|
||||
|
||||
### Step 2: Generate API Key
|
||||
|
||||
1. **Access API Settings**
|
||||
- Log in as the service account
|
||||
- Go to **Settings** > **API**
|
||||
|
||||
2. **Create Personal API Key**
|
||||
- Click **Create new key**
|
||||
- Name: `OpenHands Cloud Integration`
|
||||
- Scopes: Select the following:
|
||||
- `Read` - Read access to issues and comments
|
||||
- `Create comments` - Ability to create or update comments
|
||||
- Select the teams you want to provide access to, or allow access for all teams you have permissions for
|
||||
- Click **Create**
|
||||
- **Important**: Copy and store the API key securely
|
||||
|
||||
### Step 3: Configure Webhook
|
||||
|
||||
1. **Access Webhook Settings**
|
||||
- Go to **Settings** > **API** > **Webhooks**
|
||||
- Click **New webhook**
|
||||
|
||||
2. **Configure Webhook**
|
||||
- **Label**: `OpenHands Cloud Integration`
|
||||
- **URL**: `https://app.all-hands.dev/integration/linear/events`
|
||||
- **Resource types**: Select:
|
||||
- `Comment` - For comment events
|
||||
- `Issue` - For issue updates (label changes)
|
||||
- Select the teams you want to provide access to, or allow access for all public teams
|
||||
- Click **Create webhook**
|
||||
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
|
||||
|
||||
---
|
||||
|
||||
## Workspace Integration
|
||||
|
||||
### Step 1: Log in to OpenHands Cloud
|
||||
|
||||
1. **Navigate and Authenticate**
|
||||
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
|
||||
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
|
||||
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
|
||||
|
||||
### Step 2: Configure Linear Integration
|
||||
|
||||
1. **Access Integration Settings**
|
||||
- Navigate to **Settings** > **Integrations**
|
||||
- Locate **Linear** section
|
||||
|
||||
2. **Configure Workspace**
|
||||
- Click **Configure** button
|
||||
- Enter your workspace name and click **Connect**
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
- You'll be redirected to Linear to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
|
||||
|
||||
### Managing Your Integration
|
||||
|
||||
**Edit Configuration:**
|
||||
- Click the **Edit** button next to your configured platform
|
||||
- Update any necessary credentials or settings
|
||||
- Click **Update** to apply changes
|
||||
- You will need to repeat the OAuth flow as before
|
||||
- **Important:** Only the original user who created the integration can see the edit view
|
||||
|
||||
**Unlink Workspace:**
|
||||
- In the edit view, click **Unlink** next to the workspace name
|
||||
- This will deactivate your workspace link
|
||||
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
|
||||
|
||||
### Screenshots
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Workspace link flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Workspace Configure flow">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as a user">
|
||||

|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Edit view as the workspace creator">
|
||||

|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
79
docs/usage/cloud/project-management/overview.mdx
Normal file
79
docs/usage/cloud/project-management/overview.mdx
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
title: Project Management Tool Integrations
|
||||
description: Overview of OpenHands Cloud integrations with project management platforms including Jira Cloud, Jira Data Center, and Linear. Learn about setup requirements, usage methods, and troubleshooting.
|
||||
---
|
||||
|
||||
# Project Management Tool Integrations
|
||||
|
||||
## Overview
|
||||
|
||||
OpenHands Cloud integrates with project management platforms (Jira Cloud, Jira Data Center, and Linear) to enable AI-powered task delegation. Users can invoke the OpenHands agent by:
|
||||
- Adding `@openhands` in ticket comments
|
||||
- Adding the `openhands` label to tickets
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Integration requires two levels of setup:
|
||||
1. **Platform Configuration** - Administrative setup of service accounts and webhooks on your project management platform (see individual platform documentation below)
|
||||
2. **Workspace Integration** - Self-service configuration through the OpenHands Cloud UI to link your OpenHands account to the target workspace
|
||||
|
||||
### Platform-Specific Setup Guides:
|
||||
- [Jira Cloud Integration](./jira-integration.md)
|
||||
- [Jira Data Center Integration](./jira-dc-integration.md)
|
||||
- [Linear Integration](./linear-integration.md)
|
||||
|
||||
## Usage
|
||||
|
||||
Once both the platform configuration and workspace integration are completed, users can trigger the OpenHands agent within their project management platforms using two methods:
|
||||
|
||||
### Method 1: Comment Mention
|
||||
Add a comment to any issue with `@openhands` followed by your task description:
|
||||
```
|
||||
@openhands Please implement the user authentication feature described in this ticket
|
||||
```
|
||||
|
||||
### Method 2: Label-based Delegation
|
||||
Add the label `openhands` to any issue. The OpenHands agent will automatically process the issue based on its description and requirements.
|
||||
|
||||
### Git Repository Detection
|
||||
|
||||
The OpenHands agent needs to identify which Git repository to work with when processing your issues. Here's how to ensure proper repository detection:
|
||||
|
||||
#### Specifying the Target Repository
|
||||
|
||||
**Required:** Include the target Git repository in your issue description or comment to ensure the agent works with the correct codebase.
|
||||
|
||||
**Supported Repository Formats:**
|
||||
- Full HTTPS URL: `https://github.com/owner/repository.git`
|
||||
- GitHub URL without .git: `https://github.com/owner/repository`
|
||||
- Owner/repository format: `owner/repository`
|
||||
|
||||
#### Platform-Specific Behavior
|
||||
|
||||
**Linear Integration:** When GitHub integration is enabled for your Linear workspace with issue sync activated, the target repository is automatically detected from the linked GitHub issue. Manual specification is not required in this configuration.
|
||||
|
||||
**Jira Integrations:** Always include the repository information in your issue description or `@openhands` comment to ensure proper repository detection.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Platform Configuration Issues
|
||||
- **Webhook not triggering**: Verify the webhook URL is correct and the proper event types are selected (Comment, Issue updated)
|
||||
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted
|
||||
- **Permission errors**: Ensure the service account has access to relevant projects/teams and appropriate permissions
|
||||
|
||||
### Workspace Integration Issues
|
||||
- **Workspace linking requests credentials**: If there are no active workspace integrations for the workspace you specified, you need to configure it first. Contact your platform administrator that you want to integrate with (eg: Jira, Linear)
|
||||
- **OAuth flow fails**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
|
||||
- **Integration not found**: Verify the workspace name matches exactly and that platform configuration was completed first
|
||||
|
||||
### General Issues
|
||||
- **Agent not responding**: Check webhook logs in your platform settings and verify service account status
|
||||
- **Authentication errors**: Verify Git provider permissions and OpenHands Cloud access
|
||||
- **Partial functionality**: Ensure both platform configuration and workspace integration are properly completed
|
||||
|
||||
### Getting Help
|
||||
For additional support, contact OpenHands Cloud support with:
|
||||
- Your integration platform (Linear, Jira Cloud, or Jira Data Center)
|
||||
- Workspace name
|
||||
- Error logs from webhook/integration attempts
|
||||
- Screenshots of configuration settings (without sensitive credentials)
|
||||
@@ -12,6 +12,10 @@ description: This guide walks you through installing the OpenHands Slack app.
|
||||
allowFullScreen>
|
||||
</iframe>
|
||||
|
||||
<Info>
|
||||
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
|
||||
</Info>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Access to OpenHands Cloud.
|
||||
|
||||
@@ -20,7 +20,7 @@ for scripting.
|
||||
|
||||
### Running with Python
|
||||
|
||||
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported)
|
||||
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported) and `uvx` for the default `fetch` MCP server (more details below).
|
||||
|
||||
1. Install OpenHands using pip:
|
||||
```bash
|
||||
@@ -103,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -112,7 +112,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
@@ -186,7 +186,7 @@ To configure Model Context Protocol (MCP) servers, you can refer to the document
|
||||
|
||||
This command modifies your `~/.openhands/config.toml` file and will prompt you to restart OpenHands for changes to take effect.
|
||||
|
||||
To enable the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) search engine, you can set the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
|
||||
By default, the [Fetch MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) will be automatically configured for OpenHands. You can also [enable search engine](../search-engine-setup) via the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) by setting the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
|
||||
|
||||
##### Example of the `config.toml` file with MCP server configuration:
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
@@ -30,5 +30,6 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
|
||||
## Pricing
|
||||
|
||||
Pricing follows official API provider rates.
|
||||
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
|
||||
|
||||
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.
|
||||
|
||||
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.50
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.51
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
@@ -100,6 +100,16 @@ OpenHands requires an API key to access most language models. Here's how to get
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="OpenHands (Recommended)">
|
||||
|
||||
1. [Log in to OpenHands Cloud](https://app.all-hands.dev).
|
||||
2. Go to the Settings page and navigate to the `API Keys` tab.
|
||||
3. Copy your `LLM API Key`.
|
||||
|
||||
OpenHands provides access to state-of-the-art agentic coding models with competitive pricing. [Learn more about OpenHands LLM provider](/usage/llms/openhands-llms).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Anthropic (Claude)">
|
||||
|
||||
1. [Create an Anthropic account](https://console.anthropic.com/).
|
||||
|
||||
@@ -183,24 +183,7 @@ The final results will be saved to `evaluation/evaluation_outputs/outputs/swe_be
|
||||
- `report.json`: a JSON file that contains keys like `"resolved_ids"` pointing to instance IDs that are resolved by the agent.
|
||||
- `logs/`: a directory of test logs
|
||||
|
||||
### Run evaluation with `RemoteRuntime`
|
||||
|
||||
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to run rollout in parallel in the cloud, so you don't need a powerful machine to run evaluation.
|
||||
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
|
||||
|
||||
```bash
|
||||
./evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh [output.jsonl filepath] [num_workers]
|
||||
|
||||
# Example - This evaluates patches generated by CodeActAgent on Llama-3.1-70B-Instruct-Turbo on "princeton-nlp/SWE-bench_Lite"'s test set, with 16 number of workers running in parallel
|
||||
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
|
||||
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_100_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
|
||||
```
|
||||
|
||||
To clean-up all existing runtimes that you've already started, run:
|
||||
|
||||
```bash
|
||||
ALLHANDS_API_KEY="YOUR-API-KEY" ./evaluation/utils/scripts/cleanup_remote_runtime.sh
|
||||
```
|
||||
|
||||
## SWT-Bench Evaluation
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
INPUT_FILE=$1
|
||||
NUM_WORKERS=$2
|
||||
DATASET=$3
|
||||
SPLIT=$4
|
||||
|
||||
if [ -z "$INPUT_FILE" ]; then
|
||||
echo "INPUT_FILE not specified (should be a path to a jsonl file)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$DATASET" ]; then
|
||||
echo "DATASET not specified, use default princeton-nlp/SWE-bench_Lite"
|
||||
DATASET="princeton-nlp/SWE-bench_Lite"
|
||||
fi
|
||||
|
||||
if [ -z "$SPLIT" ]; then
|
||||
echo "SPLIT not specified, use default test"
|
||||
SPLIT="test"
|
||||
fi
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
echo "NUM_WORKERS not specified, use default 1"
|
||||
NUM_WORKERS=1
|
||||
fi
|
||||
|
||||
echo "... Evaluating on $INPUT_FILE ..."
|
||||
|
||||
COMMAND="poetry run python evaluation/benchmarks/swe_bench/eval_infer.py \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--input-file $INPUT_FILE \
|
||||
--dataset $DATASET \
|
||||
--split $SPLIT"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
|
||||
# update the output with evaluation results
|
||||
poetry run python evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py $INPUT_FILE
|
||||
@@ -5,8 +5,7 @@ pynguin_ids = ['pydata__xarray-6548-16541', 'pydata__xarray-7003-16557', 'pydata
|
||||
ids = ['pydata__xarray-3114-16452', 'pydata__xarray-3151-16453', 'pydata__xarray-3156-16454', 'pydata__xarray-3239-16456', 'pydata__xarray-3239-16457', 'pydata__xarray-3239-16458', 'pydata__xarray-3302-16459', 'pydata__xarray-3364-16461', 'pydata__xarray-3677-16471', 'pydata__xarray-3905-16478', 'pydata__xarray-4182-16484', 'pydata__xarray-4248-16486', 'pydata__xarray-4339-16487', 'pydata__xarray-4419-16488', 'pydata__xarray-4629-16492', 'pydata__xarray-4750-16496', 'pydata__xarray-4802-16505', 'pydata__xarray-4966-16515', 'pydata__xarray-4994-16516', 'pydata__xarray-5033-16517', 'pydata__xarray-5126-16518', 'pydata__xarray-5126-16519', 'pydata__xarray-5131-16520', 'pydata__xarray-5365-16529', 'pydata__xarray-5455-16530', 'pydata__xarray-5662-16532', 'pydata__xarray-5731-16534', 'pydata__xarray-6135-16535', 'pydata__xarray-6135-16536', 'pydata__xarray-6386-16537', 'pydata__xarray-6394-16538', 'pydata__xarray-6400-16539', 'pydata__xarray-6461-16540', 'pydata__xarray-6548-16541', 'pydata__xarray-6599-16543', 'pydata__xarray-6601-16544', 'pydata__xarray-6882-16548', 'pydata__xarray-6889-16549', 'pydata__xarray-7003-16557', 'pydata__xarray-7147-16571', 'pydata__xarray-7150-16572', 'pydata__xarray-7203-16577', 'pydata__xarray-7229-16578', 'pydata__xarray-7393-16581', 'pydata__xarray-7400-16582']
|
||||
|
||||
|
||||
Command eval (our approach):
|
||||
poetry run ./evaluation/benchmarks/testgeneval/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/kjain14__testgeneval-test/CodeActAgent/gpt-4o_maxiter_25_N_v0.20.0-no-hint-run_1/output.jsonl 10 kjain14/testgeneval test true
|
||||
|
||||
|
||||
Command run (our approach):
|
||||
./evaluation/benchmarks/testgeneval/scripts/run_infer.sh llm.eval_gpt HEAD CodeActAgent -1 25 10 kjain14/testgeneval test 1 ../TestGenEval/results/testgeneval/preds/gpt-4o-2024-08-06__testgeneval__0.2__test.jsonl
|
||||
|
||||
@@ -120,6 +120,9 @@ describe("ExpandableMessage", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
const RouterStub = createRoutesStub([
|
||||
|
||||
@@ -28,7 +28,6 @@ describe("EventMessage", () => {
|
||||
action: "finish" as const,
|
||||
args: {
|
||||
final_thought: "Task completed successfully",
|
||||
task_completed: "success" as const,
|
||||
outputs: {},
|
||||
thought: "Task completed successfully",
|
||||
},
|
||||
@@ -114,7 +113,6 @@ describe("EventMessage", () => {
|
||||
action: "finish" as const,
|
||||
args: {
|
||||
final_thought: "Task completed successfully",
|
||||
task_completed: "success" as const,
|
||||
outputs: {},
|
||||
thought: "Task completed successfully",
|
||||
},
|
||||
|
||||
@@ -85,17 +85,36 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the search function that's used by the dropdown
|
||||
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
|
||||
MOCK_RESPOSITORIES,
|
||||
);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
screen.getByText("rbren/polaris");
|
||||
screen.getByText("All-Hands-AI/OpenHands");
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
expect(screen.getByText("All-Hands-AI/OpenHands")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,18 +123,47 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -180,7 +228,10 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -192,14 +243,37 @@ describe("RepoConnector", () => {
|
||||
// repo not selected yet
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(dropdown);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const repoOption = screen.getByText("rbren/polaris");
|
||||
await userEvent.click(repoOption);
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
@@ -218,17 +292,46 @@ describe("RepoConnector", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
const launchButton = await screen.findByTestId("repo-launch-button");
|
||||
|
||||
// Wait for the loading state to be replaced with the dropdown
|
||||
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
|
||||
await userEvent.click(dropdown);
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(screen.getByText("rbren/polaris"));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
expect(launchButton).toBeDisabled();
|
||||
expect(launchButton).toHaveTextContent(/Loading/i);
|
||||
|
||||
@@ -12,6 +12,8 @@ const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseUserProviders = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
@@ -30,6 +32,29 @@ mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
// Default mock for useGitRepositories
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
@@ -71,6 +96,10 @@ vi.mock("react-router", async (importActual) => ({
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-git-repositories", () => ({
|
||||
useGitRepositories: () => mockUseGitRepositories(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@@ -96,34 +125,6 @@ describe("RepositorySelectionForm", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows loading indicator when repositories are being fetched", () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
renderForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows dropdown when repositories are loaded", async () => {
|
||||
const MOCK_REPOS: GitRepository[] = [
|
||||
{
|
||||
@@ -139,24 +140,30 @@ describe("RepositorySelectionForm", () => {
|
||||
is_public: true,
|
||||
},
|
||||
];
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to load"),
|
||||
);
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
@@ -194,40 +201,45 @@ describe("RepositorySelectionForm", () => {
|
||||
];
|
||||
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
await userEvent.click(input);
|
||||
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
|
||||
}
|
||||
expect(
|
||||
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(searchGitReposSpy).not.toHaveBeenCalled();
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
|
||||
).toBeInTheDocument();
|
||||
for (const repo of MOCK_REPOS) {
|
||||
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("should call onRepoSelection when a searched repository is selected", async () => {
|
||||
@@ -243,20 +255,26 @@ describe("RepositorySelectionForm", () => {
|
||||
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
|
||||
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
|
||||
|
||||
mockUseGitRepositories.mockReturnValue({
|
||||
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
fetchNextPage: vi.fn(),
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderForm();
|
||||
|
||||
const input = await screen.findByTestId("repo-dropdown");
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
"kubernetes/kubernetes",
|
||||
3,
|
||||
);
|
||||
|
||||
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,7 @@ describe("TaskCard", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
|
||||
});
|
||||
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
|
||||
@@ -12,6 +12,23 @@ import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
// Mock hooks
|
||||
const mockUseUserProviders = vi.fn();
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => mockUseConfig(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -105,22 +122,12 @@ describe("MicroagentManagement", () => {
|
||||
const mockMicroagents: RepositoryMicroagent[] = [
|
||||
{
|
||||
name: "test-microagent-1",
|
||||
type: "repo",
|
||||
content: "Test microagent content 1",
|
||||
triggers: ["test", "microagent"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent-1",
|
||||
},
|
||||
{
|
||||
name: "test-microagent-2",
|
||||
type: "knowledge",
|
||||
content: "Test microagent content 2",
|
||||
triggers: ["knowledge", "test"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent-2",
|
||||
@@ -161,10 +168,39 @@ describe("MicroagentManagement", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
|
||||
// Setup default hook mocks
|
||||
mockUseUserProviders.mockReturnValue({
|
||||
providers: ["github"],
|
||||
});
|
||||
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: mockRepositories,
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
mockUseConfig.mockReturnValue({
|
||||
data: {
|
||||
APP_MODE: "oss",
|
||||
},
|
||||
});
|
||||
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
|
||||
...mockRepositories,
|
||||
]);
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
|
||||
data: [...mockRepositories],
|
||||
nextPage: null,
|
||||
});
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
@@ -173,6 +209,13 @@ describe("MicroagentManagement", () => {
|
||||
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
|
||||
...mockConversations,
|
||||
]);
|
||||
// Setup default mock for getRepositoryMicroagentContent
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Original microagent content for testing updates",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: ["test", "update"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the microagent management page", async () => {
|
||||
@@ -183,13 +226,15 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display loading state when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
// Mock loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -199,19 +244,21 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should handle error when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch repositories"),
|
||||
);
|
||||
// Mock error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -220,7 +267,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that tabs are rendered
|
||||
@@ -238,7 +285,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded and rendered
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that repository names are displayed
|
||||
@@ -253,7 +300,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -290,7 +337,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -315,7 +362,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -340,7 +387,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -363,7 +410,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -402,7 +449,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@@ -416,7 +463,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -435,7 +482,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -455,17 +502,28 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
it("should display empty state when no repositories are found", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
|
||||
// Mock empty repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: {
|
||||
pages: [
|
||||
{
|
||||
data: [],
|
||||
nextPage: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
hasNextPage: false,
|
||||
isFetchingNextPage: false,
|
||||
onLoadMore: vi.fn(),
|
||||
});
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that empty state messages are displayed
|
||||
@@ -482,7 +540,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -523,7 +581,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that search input is rendered
|
||||
@@ -543,7 +601,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Initially only repositories with .openhands should be visible
|
||||
@@ -574,7 +632,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with uppercase
|
||||
@@ -597,7 +655,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with partial match
|
||||
@@ -623,7 +681,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input
|
||||
@@ -656,7 +714,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with non-existent repository name
|
||||
@@ -684,7 +742,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with special characters
|
||||
@@ -705,7 +763,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Filter to show only repo2
|
||||
@@ -740,7 +798,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with leading/trailing whitespace
|
||||
@@ -760,7 +818,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
@@ -792,7 +850,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -819,7 +877,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -865,7 +923,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -882,7 +940,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -907,7 +965,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -956,7 +1014,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -992,7 +1050,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1034,7 +1092,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1071,7 +1129,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1115,7 +1173,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1145,7 +1203,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1168,7 +1226,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1187,17 +1245,6 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
expect(conversation1).toBeInTheDocument();
|
||||
expect(conversation2).toBeInTheDocument();
|
||||
|
||||
// Check that created dates are displayed for conversations (there are multiple elements with the same text)
|
||||
const createdDates = screen.getAllByText(
|
||||
/COMMON\$CREATED_ON.*10\/01\/2021/,
|
||||
);
|
||||
expect(createdDates.length).toBeGreaterThan(0);
|
||||
|
||||
const createdDates2 = screen.getAllByText(
|
||||
/COMMON\$CREATED_ON.*10\/02\/2021/,
|
||||
);
|
||||
expect(createdDates2.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should handle multiple repository expansions with conversations", async () => {
|
||||
@@ -1206,7 +1253,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
@@ -1247,7 +1294,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
@@ -1261,7 +1308,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1316,7 +1363,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1340,7 +1387,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1363,7 +1410,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1396,7 +1443,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1423,7 +1470,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1449,7 +1496,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
@@ -1475,11 +1522,6 @@ describe("MicroagentManagement", () => {
|
||||
describe("MicroagentManagementMain", () => {
|
||||
const mockRepositoryMicroagent: RepositoryMicroagent = {
|
||||
name: "test-microagent",
|
||||
type: "repo",
|
||||
content: "Test microagent content",
|
||||
triggers: ["test", "microagent"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test-microagent",
|
||||
@@ -1533,8 +1575,8 @@ describe("MicroagentManagement", () => {
|
||||
pr_number: null,
|
||||
};
|
||||
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
|
||||
return renderWithProviders(<MicroagentManagementMain />, {
|
||||
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
|
||||
renderWithProviders(<MicroagentManagementMain />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
@@ -1560,7 +1602,6 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
|
||||
renderMicroagentManagementMain(null);
|
||||
@@ -1820,11 +1861,6 @@ describe("MicroagentManagement", () => {
|
||||
it("should handle microagent with all required properties", async () => {
|
||||
const completeMicroagent: RepositoryMicroagent = {
|
||||
name: "complete-microagent",
|
||||
type: "knowledge",
|
||||
content: "Complete microagent content with all properties",
|
||||
triggers: ["complete", "test"],
|
||||
inputs: ["input1", "input2"],
|
||||
tools: ["tool1", "tool2"],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/complete-microagent",
|
||||
@@ -1874,11 +1910,6 @@ describe("MicroagentManagement", () => {
|
||||
describe("Update microagent functionality", () => {
|
||||
const mockMicroagentForUpdate: RepositoryMicroagent = {
|
||||
name: "update-test-microagent",
|
||||
type: "repo",
|
||||
content: "Original microagent content for testing updates",
|
||||
triggers: ["original", "test"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
@@ -1999,11 +2030,13 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Check that the form fields are populated with existing data
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue(
|
||||
"Original microagent content for testing updates",
|
||||
);
|
||||
// Wait for the content to be loaded and form fields to be populated
|
||||
await waitFor(() => {
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue(
|
||||
"Original microagent content for testing updates",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle update microagent form submission", async () => {
|
||||
@@ -2207,12 +2240,16 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle update modal with microagent that has no content", async () => {
|
||||
const user = userEvent.setup();
|
||||
const microagentWithoutContent = {
|
||||
...mockMicroagentForUpdate,
|
||||
content: "",
|
||||
};
|
||||
|
||||
// Render with update modal visible and microagent without content
|
||||
// Mock the content API to return empty content for this test
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: [],
|
||||
});
|
||||
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
@@ -2222,7 +2259,7 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: microagentWithoutContent,
|
||||
microagent: mockMicroagentForUpdate,
|
||||
conversation: undefined,
|
||||
},
|
||||
addMicroagentModalVisible: false,
|
||||
@@ -2243,19 +2280,25 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Check that the form field is empty
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue("");
|
||||
// Wait for the content to be loaded and check that the form field is empty
|
||||
await waitFor(() => {
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle update modal with microagent that has no triggers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const microagentWithoutTriggers = {
|
||||
...mockMicroagentForUpdate,
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
// Render with update modal visible and microagent without triggers
|
||||
// Mock the content API to return content without triggers for this test
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Original microagent content for testing updates",
|
||||
path: ".openhands/microagents/update-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: [],
|
||||
});
|
||||
|
||||
// Render with update modal visible and microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
@@ -2265,7 +2308,7 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: microagentWithoutTriggers,
|
||||
microagent: mockMicroagentForUpdate,
|
||||
conversation: undefined,
|
||||
},
|
||||
addMicroagentModalVisible: false,
|
||||
@@ -2312,7 +2355,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion to expand it
|
||||
@@ -2354,7 +2397,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for repositories and expand accordion
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
@@ -2397,11 +2440,6 @@ describe("MicroagentManagement", () => {
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([
|
||||
{
|
||||
name: "test-microagent",
|
||||
type: "repo",
|
||||
content: "Test content",
|
||||
triggers: [],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/test",
|
||||
@@ -2412,7 +2450,7 @@ describe("MicroagentManagement", () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
expect(mockUseUserRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
@@ -2486,11 +2524,6 @@ describe("MicroagentManagement", () => {
|
||||
describe("Learn something new button functionality", () => {
|
||||
const mockMicroagentForLearn: RepositoryMicroagent = {
|
||||
name: "learn-test-microagent",
|
||||
type: "repo",
|
||||
content: "Test microagent content for learn functionality",
|
||||
triggers: ["learn", "test"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
@@ -2586,6 +2619,14 @@ describe("MicroagentManagement", () => {
|
||||
it("should populate form fields with current microagent data when learn button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock the content API to return the expected content for this test
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Test microagent content for learn functionality",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: ["learn", "test"],
|
||||
});
|
||||
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
@@ -2626,21 +2667,27 @@ describe("MicroagentManagement", () => {
|
||||
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the form fields are populated with current microagent data
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue(
|
||||
"Test microagent content for learn functionality",
|
||||
);
|
||||
// Wait for the content to be loaded and form to be populated
|
||||
await waitFor(() => {
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue(
|
||||
"Test microagent content for learn functionality",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle learn button click with microagent that has no content", async () => {
|
||||
const user = userEvent.setup();
|
||||
const microagentWithoutContent = {
|
||||
...mockMicroagentForLearn,
|
||||
content: "",
|
||||
};
|
||||
|
||||
// Render with selected microagent without content
|
||||
// Mock the content API to return empty content for this test
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: [],
|
||||
});
|
||||
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
@@ -2650,7 +2697,7 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: microagentWithoutContent,
|
||||
microagent: mockMicroagentForLearn,
|
||||
conversation: undefined,
|
||||
},
|
||||
addMicroagentModalVisible: false,
|
||||
@@ -2680,19 +2727,25 @@ describe("MicroagentManagement", () => {
|
||||
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check that the form field is empty
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue("");
|
||||
// Wait for the content to be loaded and check that the form field is empty
|
||||
await waitFor(() => {
|
||||
const queryInput = screen.getByTestId("query-input");
|
||||
expect(queryInput).toHaveValue("");
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle learn button click with microagent that has no triggers", async () => {
|
||||
const user = userEvent.setup();
|
||||
const microagentWithoutTriggers = {
|
||||
...mockMicroagentForLearn,
|
||||
triggers: [],
|
||||
};
|
||||
|
||||
// Render with selected microagent without triggers
|
||||
// Mock the content API to return content without triggers for this test
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
|
||||
content: "Test microagent content for learn functionality",
|
||||
path: ".openhands/microagents/learn-test-microagent",
|
||||
git_provider: "github",
|
||||
triggers: [],
|
||||
});
|
||||
|
||||
// Render with selected microagent
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
@@ -2702,7 +2755,7 @@ describe("MicroagentManagement", () => {
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagentItem: {
|
||||
microagent: microagentWithoutTriggers,
|
||||
microagent: mockMicroagentForLearn,
|
||||
conversation: undefined,
|
||||
},
|
||||
addMicroagentModalVisible: false,
|
||||
|
||||
@@ -28,6 +28,9 @@ describe("PaymentForm", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,82 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, test, vi, afterEach } from "vitest";
|
||||
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { UserActions } from "#/components/features/sidebar/user-actions";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ReactElement } from "react";
|
||||
|
||||
// Create mocks for all the hooks we need
|
||||
const useIsAuthedMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
const useConfigMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
|
||||
const useUserProvidersMock = vi
|
||||
.fn()
|
||||
.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => useIsAuthedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => useConfigMock(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => useUserProvidersMock(),
|
||||
}));
|
||||
|
||||
describe("UserActions", () => {
|
||||
const user = userEvent.setup();
|
||||
const onClickAccountSettingsMock = vi.fn();
|
||||
const onLogoutMock = vi.fn();
|
||||
|
||||
// Create a wrapper with QueryClientProvider
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return render(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks to default values before each test
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
onClickAccountSettingsMock.mockClear();
|
||||
onLogoutMock.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render", () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
@@ -43,7 +98,7 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should call onLogout and close the menu when the logout option is clicked", async () => {
|
||||
render(
|
||||
renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
@@ -62,20 +117,28 @@ describe("UserActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should NOT appear because user is undefined
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
|
||||
renderWithQueryClient(
|
||||
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -86,42 +149,88 @@ describe("UserActions", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when no user is provided", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
it("should NOT be able to access logout when user is not authenticated", async () => {
|
||||
// Set isAuthed to false for this test
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Logout option should not be accessible because context menu doesn't appear
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from undefined to defined", () => {
|
||||
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
// Initially no user - context menu shouldn't work
|
||||
// Context menu should NOT appear because user is not authenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Add user prop
|
||||
// Logout option should NOT be accessible when user is not authenticated
|
||||
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from undefined to defined", async () => {
|
||||
// Start with no authentication
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<UserActions onLogout={onLogoutMock} />,
|
||||
);
|
||||
|
||||
// Initially no user and not authenticated - menu should not appear
|
||||
let userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Set authentication to true for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
// Ensure config and providers are set correctly
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Add user prop and create a new QueryClient to ensure fresh state
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
rerender(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Component should still render correctly
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
|
||||
// Menu should now work with user defined and authenticated
|
||||
userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
const { rerender } = render(
|
||||
// Start with authentication and providers
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
const { rerender } = renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
@@ -135,16 +244,35 @@ describe("UserActions", () => {
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Remove user prop - menu should disappear
|
||||
rerender(<UserActions onLogout={onLogoutMock} />);
|
||||
// Set authentication to false for the rerender
|
||||
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
|
||||
// Keep other mocks with default values
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
// Remove user prop - menu should disappear because user is no longer authenticated
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<UserActions onLogout={onLogoutMock} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// Context menu should NOT be visible when user becomes unauthenticated
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Logout option should not be accessible
|
||||
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work with loading state and user provided", async () => {
|
||||
render(
|
||||
// Ensure authentication and providers are set correctly
|
||||
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
|
||||
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
|
||||
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
|
||||
|
||||
renderWithQueryClient(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useConversationId hook
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-conversation-id" }),
|
||||
}));
|
||||
|
||||
describe("useFeedbackExists", () => {
|
||||
let queryClient: QueryClient;
|
||||
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
mockCheckFeedbackExists.mockClear();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("should not call API when APP_MODE is not saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "oss" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call API when APP_MODE is saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
mockCheckFeedbackExists.mockResolvedValue({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for the query to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was called
|
||||
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
123,
|
||||
);
|
||||
|
||||
// Verify that the data is returned
|
||||
expect(result.current.data).toEqual({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call API when eventId is not provided", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(undefined), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not call API when config is not loaded yet", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,9 @@ describe("frontend/routes/_oh", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -111,6 +114,9 @@ describe("frontend/routes/_oh", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -192,6 +198,9 @@ describe("frontend/routes/_oh", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createRoutesStub } from "react-router";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import i18next from "i18next";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import GitSettingsScreen from "#/routes/git-settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
@@ -17,6 +19,9 @@ const VALID_OSS_CONFIG: GetConfigResponse = {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,6 +32,9 @@ const VALID_SAAS_CONFIG: GetConfigResponse = {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -40,22 +48,44 @@ const GitSettingsRouterStub = createRoutesStub([
|
||||
]);
|
||||
|
||||
const renderGitSettingsScreen = () => {
|
||||
// Initialize i18next instance
|
||||
i18next.init({
|
||||
lng: "en",
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
GITHUB$TOKEN_HELP_TEXT: "Help text",
|
||||
GITHUB$TOKEN_LABEL: "GitHub Token",
|
||||
GITHUB$HOST_LABEL: "GitHub Host",
|
||||
GITLAB$TOKEN_LABEL: "GitLab Token",
|
||||
GITLAB$HOST_LABEL: "GitLab Host",
|
||||
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
|
||||
BITBUCKET$HOST_LABEL: "Bitbucket Host",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender, ...rest } = render(
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderGitSettingsScreen = () =>
|
||||
rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>,
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
|
||||
</QueryClientProvider>
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -345,14 +375,18 @@ describe("Form submission", () => {
|
||||
let disconnectButton = await screen.findByTestId(
|
||||
"disconnect-tokens-button",
|
||||
);
|
||||
// When tokens are set (github and gitlab are not null), the button should be enabled
|
||||
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
|
||||
|
||||
// Mock settings with no tokens set
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
|
||||
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
|
||||
// When no tokens are set, the button should be disabled
|
||||
await waitFor(() => expect(disconnectButton).toBeDisabled());
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,42 @@ const RouterStub = createRoutesStub([
|
||||
},
|
||||
]);
|
||||
|
||||
const selectRepository = async (repoName: string) => {
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
await waitFor(() => {
|
||||
const options = screen.getAllByText(repoName);
|
||||
// Find the option in the dropdown (it will have role="option")
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
expect(dropdownOption).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
});
|
||||
};
|
||||
|
||||
const renderHomeScreen = () =>
|
||||
render(<RouterStub />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -93,84 +129,8 @@ describe("HomeScreen", () => {
|
||||
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
|
||||
});
|
||||
|
||||
it("should filter the suggested tasks based on the selected repository", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should reset the filtered tasks when the selected repository is cleared", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = await screen.findByTestId("task-suggestions");
|
||||
|
||||
// Initially, all tasks should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
|
||||
// Select a repository from the dropdown
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
|
||||
// After selecting a repository, only tasks related to that repository should be visible
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
expect(
|
||||
within(taskSuggestions).queryByText("octocat/earth"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Clear the selected repository
|
||||
await userEvent.clear(dropdown);
|
||||
|
||||
// All tasks should be visible again
|
||||
await waitFor(() => {
|
||||
within(taskSuggestions).getByText("octocat/hello-world");
|
||||
within(taskSuggestions).getByText("octocat/earth");
|
||||
});
|
||||
});
|
||||
// TODO: Fix this test
|
||||
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
|
||||
|
||||
describe("launch buttons", () => {
|
||||
const setupLaunchButtons = async () => {
|
||||
@@ -179,19 +139,25 @@ describe("HomeScreen", () => {
|
||||
let tasksLaunchButtons =
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Select a repository from the dropdown to enable the repo launch button
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
await userEvent.click(dropdown);
|
||||
const repoOption = screen.getAllByText("octocat/hello-world")[1];
|
||||
await userEvent.click(repoOption);
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
// Wait for all buttons to be enabled
|
||||
await waitFor(() => {
|
||||
expect(headerLaunchButton).not.toBeDisabled();
|
||||
expect(repoLaunchButton).not.toBeDisabled();
|
||||
tasksLaunchButtons.forEach((button) => {
|
||||
expect(button).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
// Get fresh references to the buttons
|
||||
headerLaunchButton = screen.getByTestId("header-launch-button");
|
||||
repoLaunchButton = screen.getByTestId("repo-launch-button");
|
||||
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
|
||||
@@ -208,7 +174,10 @@ describe("HomeScreen", () => {
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable the other launch buttons when the header launch button is clicked", async () => {
|
||||
@@ -333,6 +302,9 @@ describe("Settings 404", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
@@ -355,6 +327,9 @@ describe("Setup Payment modal", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Content", () => {
|
||||
const apiKey = screen.getByTestId("llm-api-key-input");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(provider).toHaveValue("Anthropic");
|
||||
expect(provider).toHaveValue("OpenHands");
|
||||
expect(model).toHaveValue("claude-sonnet-4-20250514");
|
||||
|
||||
expect(apiKey).toHaveValue("");
|
||||
@@ -135,7 +135,7 @@ describe("Content", () => {
|
||||
);
|
||||
const condensor = screen.getByTestId("enable-memory-condenser-switch");
|
||||
|
||||
expect(model).toHaveValue("anthropic/claude-sonnet-4-20250514");
|
||||
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
|
||||
expect(baseUrl).toHaveValue("");
|
||||
expect(apiKey).toHaveValue("");
|
||||
expect(apiKey).toHaveProperty("placeholder", "");
|
||||
@@ -537,7 +537,7 @@ describe("Form submission", () => {
|
||||
|
||||
// select provider
|
||||
await userEvent.click(provider);
|
||||
const providerOption = screen.getByText("Anthropic");
|
||||
const providerOption = screen.getByText("OpenHands");
|
||||
await userEvent.click(providerOption);
|
||||
|
||||
// select model
|
||||
@@ -550,7 +550,7 @@ describe("Form submission", () => {
|
||||
|
||||
expect(saveSettingsSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
llm_model: "anthropic/claude-sonnet-4-20250514",
|
||||
llm_model: "openhands/claude-sonnet-4-20250514",
|
||||
llm_base_url: "",
|
||||
confirmation_mode: false,
|
||||
}),
|
||||
|
||||
@@ -101,7 +101,8 @@ describe("Content", () => {
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
expect(getSecretsSpy).not.toHaveBeenCalled();
|
||||
// In SAAS mode, getSecrets is still called because the user is authenticated
|
||||
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
@@ -111,12 +112,21 @@ describe("Content", () => {
|
||||
screen.getByTestId("git-settings-screen");
|
||||
});
|
||||
|
||||
it("should render a message if there are no existing secrets", async () => {
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
await screen.findByTestId("no-secrets-message");
|
||||
// Should show the add secret button
|
||||
await screen.findByTestId("add-secret-button");
|
||||
|
||||
// Should show an empty table with headers but no secret items
|
||||
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
|
||||
|
||||
// Should still show the table headers
|
||||
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument();
|
||||
expect(screen.getByText("SECRETS$DESCRIPTION")).toBeInTheDocument();
|
||||
expect(screen.getByText("SETTINGS$ACTIONS")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render existing secrets", async () => {
|
||||
@@ -126,7 +136,6 @@ describe("Content", () => {
|
||||
|
||||
const secrets = await screen.findAllByTestId("secret-item");
|
||||
expect(secrets).toHaveLength(2);
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,19 +407,22 @@ describe("Secret actions", () => {
|
||||
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide the no items message when in form view", async () => {
|
||||
it("should hide the table and add button when in form view", async () => {
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
getSecretsSpy.mockResolvedValue([]);
|
||||
renderSecretsSettings();
|
||||
|
||||
// render form & hide items
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
// Initially should show the add button and table
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument(); // table header
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
// When in form view, should hide the add button and table
|
||||
const secretForm = screen.getByTestId("add-secret-form");
|
||||
expect(secretForm).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("SETTINGS$NAME")).not.toBeInTheDocument(); // table header should be hidden
|
||||
});
|
||||
|
||||
it("should not allow spaces in secret names", async () => {
|
||||
|
||||
@@ -86,6 +86,9 @@ describe("Settings Billing", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: false,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,6 +107,9 @@ describe("Settings Billing", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -122,6 +128,9 @@ describe("Settings Billing", () => {
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -82,5 +82,11 @@ describe("extractModelAndProvider", () => {
|
||||
model: "claude-opus-4-20250514",
|
||||
separator: "/",
|
||||
});
|
||||
|
||||
expect(extractModelAndProvider("claude-opus-4-1-20250805")).toEqual({
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-1-20250805",
|
||||
separator: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
|
||||
<meta name="msapplication-TileColor" content="#da532c">
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="OpenHands: Code Less, Make More"
|
||||
/>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>OpenHands</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1626
frontend/package-lock.json
generated
1626
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.50.0",
|
||||
"version": "0.51.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -8,32 +8,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.2",
|
||||
"@heroui/use-infinite-scroll": "^2.2.10",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.7.1",
|
||||
"@react-router/serve": "^7.7.1",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.8.1",
|
||||
"@stripe/stripe-js": "^7.7.0",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-query": "^5.84.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.11",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.533.0",
|
||||
"lucide-react": "^0.536.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.258.2",
|
||||
"posthog-js": "^1.258.5",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.7.1",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@@ -51,7 +53,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.6",
|
||||
"web-vitals": "^5.0.3",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -84,15 +86,15 @@
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.7.1",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.2",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.6.4",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -110,18 +112,18 @@
|
||||
"eslint-plugin-i18next": "^6.1.3",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"lint-staged": "^16.1.4",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.3.0",
|
||||
"stripe": "^18.4.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.2"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.3'
|
||||
const PACKAGE_VERSION = '2.10.4'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
GetMicroagentsResponse,
|
||||
GetMicroagentPromptResponse,
|
||||
CreateMicroagent,
|
||||
MicroagentContentResponse,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
|
||||
|
||||
class OpenHands {
|
||||
private static currentConversation: Conversation | null = null;
|
||||
@@ -166,6 +169,38 @@ class OpenHands {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feedback for multiple events in a conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns Map of event IDs to feedback data including existence, rating, reason and metadata
|
||||
*/
|
||||
static async getBatchFeedback(conversationId: string): Promise<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
metadata?: Record<string, BatchFeedbackData>;
|
||||
}
|
||||
>
|
||||
> {
|
||||
const url = `/feedback/conversation/${conversationId}/batch`;
|
||||
const { data } = await openHands.get<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
metadata?: Record<string, BatchFeedbackData>;
|
||||
}
|
||||
>
|
||||
>(url);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate with GitHub token
|
||||
* @returns Response with authentication status and user info if successful
|
||||
@@ -247,7 +282,7 @@ class OpenHands {
|
||||
|
||||
static async getUserConversations(): Promise<Conversation[]> {
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/conversations?limit=20",
|
||||
"/api/conversations?limit=100",
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
@@ -400,6 +435,7 @@ class OpenHands {
|
||||
static async searchGitRepositories(
|
||||
query: string,
|
||||
per_page = 5,
|
||||
selected_provider?: Provider,
|
||||
): Promise<GitRepository[]> {
|
||||
const response = await openHands.get<GitRepository[]>(
|
||||
"/api/user/search/repositories",
|
||||
@@ -407,6 +443,7 @@ class OpenHands {
|
||||
params: {
|
||||
query,
|
||||
per_page,
|
||||
selected_provider,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -451,20 +488,70 @@ class OpenHands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a PAT, retrieves the repositories of the user
|
||||
* @returns A list of repositories
|
||||
*/
|
||||
static async retrieveUserGitRepositories() {
|
||||
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,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return data;
|
||||
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): Promise<Branch[]> {
|
||||
@@ -491,7 +578,7 @@ class OpenHands {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents for a specific repository
|
||||
* Get the available microagents for a repository
|
||||
* @param owner The repository owner
|
||||
* @param repo The repository name
|
||||
* @returns The available microagents for the repository
|
||||
@@ -506,6 +593,27 @@ class OpenHands {
|
||||
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,
|
||||
@@ -531,6 +639,18 @@ class OpenHands {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -51,10 +51,14 @@ export interface GetConfigResponse {
|
||||
POSTHOG_CLIENT_KEY: string;
|
||||
STRIPE_PUBLISHABLE_KEY?: string;
|
||||
PROVIDERS_CONFIGURED?: Provider[];
|
||||
AUTH_URL?: string;
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: boolean;
|
||||
HIDE_LLM_SETTINGS: boolean;
|
||||
HIDE_MICROAGENT_MANAGEMENT?: boolean;
|
||||
ENABLE_JIRA: boolean;
|
||||
ENABLE_JIRA_DC: boolean;
|
||||
ENABLE_LINEAR: boolean;
|
||||
};
|
||||
MAINTENANCE?: {
|
||||
startTime: string;
|
||||
@@ -147,3 +151,10 @@ export interface CreateMicroagent {
|
||||
git_provider?: Provider;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface MicroagentContentResponse {
|
||||
content: string;
|
||||
path: string;
|
||||
git_provider: Provider;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
69
frontend/src/components/common/git-branch-dropdown.tsx
Normal file
69
frontend/src/components/common/git-branch-dropdown.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repositoryName?: string | null;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (branchName: string | null) => void;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repositoryName,
|
||||
value,
|
||||
placeholder = "Select branch...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitBranchDropdownProps) {
|
||||
const { data: branches, isLoading } = useRepositoryBranches(
|
||||
repositoryName || null,
|
||||
);
|
||||
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
branches?.map((branch) => ({
|
||||
value: branch.name,
|
||||
label: branch.name,
|
||||
})) || [],
|
||||
[branches],
|
||||
);
|
||||
|
||||
const hasNoBranches = !isLoading && branches && branches.length === 0;
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value || null);
|
||||
};
|
||||
|
||||
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
|
||||
|
||||
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
|
||||
const displayErrorMessage = hasNoBranches
|
||||
? "This repository has no branches"
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={displayPlaceholder}
|
||||
className={className}
|
||||
errorMessage={displayErrorMessage}
|
||||
disabled={isDisabled}
|
||||
isClearable={false}
|
||||
isSearchable
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/common/git-provider-dropdown.tsx
Normal file
58
frontend/src/components/common/git-provider-dropdown.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useMemo } from "react";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
|
||||
|
||||
export interface GitProviderDropdownProps {
|
||||
providers: Provider[];
|
||||
value?: Provider | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (provider: Provider | null) => void;
|
||||
}
|
||||
|
||||
export function GitProviderDropdown({
|
||||
providers,
|
||||
value,
|
||||
placeholder = "Select Provider",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: GitProviderDropdownProps) {
|
||||
const options: SelectOption[] = useMemo(
|
||||
() =>
|
||||
providers.map((provider) => ({
|
||||
value: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1),
|
||||
})),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const selectedOption = useMemo(
|
||||
() => options.find((option) => option.value === value) || null,
|
||||
[options, value],
|
||||
);
|
||||
|
||||
const handleChange = (option: SelectOption | null) => {
|
||||
onChange?.(option?.value as Provider | null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactSelectDropdown
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isSearchable={false}
|
||||
isLoading={isLoading}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
186
frontend/src/components/common/git-repository-dropdown.tsx
Normal file
186
frontend/src/components/common/git-repository-dropdown.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import OpenHands from "../../api/open-hands";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import {
|
||||
ReactSelectAsyncDropdown,
|
||||
AsyncSelectOption,
|
||||
} from "./react-select-async-dropdown";
|
||||
|
||||
export interface GitRepositoryDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
interface SearchCache {
|
||||
[key: string]: GitRepository[];
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
// Keep track of search results
|
||||
const searchCache = useRef<SearchCache>({});
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
// First check in loaded pages
|
||||
const option = allOptions.find((opt) => opt.value === value);
|
||||
if (option) return option;
|
||||
|
||||
// If not found, check in search cache
|
||||
const repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === value);
|
||||
|
||||
if (repo) {
|
||||
return {
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [allOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// If it looks like a URL, extract the repo name and search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[repoName] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// For any other input, search via API
|
||||
if (inputValue.length >= 2) {
|
||||
// Only search if at least 2 characters
|
||||
const searchResults = await OpenHands.searchGitRepositories(
|
||||
inputValue,
|
||||
10,
|
||||
);
|
||||
// Cache the search results
|
||||
searchCache.current[inputValue] = searchResults;
|
||||
return searchResults.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}));
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions],
|
||||
);
|
||||
|
||||
const handleChange = (option: AsyncSelectOption | null) => {
|
||||
if (!option) {
|
||||
onChange?.(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// First check in loaded pages
|
||||
let repo = data?.pages
|
||||
?.flatMap((p) => p.data)
|
||||
.find((r) => r.id === option.value);
|
||||
|
||||
// If not found, check in search results
|
||||
if (!repo) {
|
||||
repo = Object.values(searchCache.current)
|
||||
.flat()
|
||||
.find((r) => r.id === option.value);
|
||||
}
|
||||
|
||||
onChange?.(repo);
|
||||
};
|
||||
|
||||
const handleMenuScrollToBottom = useCallback(() => {
|
||||
if (hasNextPage && !isFetchingNextPage && !isLoading) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ReactSelectAsyncDropdown
|
||||
testId="repo-dropdown"
|
||||
loadOptions={loadOptions}
|
||||
value={selectedOption}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
errorMessage={errorMessage}
|
||||
disabled={disabled}
|
||||
isClearable={false}
|
||||
isLoading={isLoading || isLoading || isFetchingNextPage}
|
||||
cacheOptions
|
||||
defaultOptions={allOptions}
|
||||
onChange={handleChange}
|
||||
onMenuScrollToBottom={handleMenuScrollToBottom}
|
||||
/>
|
||||
{isError && (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import AsyncSelect from "react-select/async";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type AsyncSelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectAsyncDropdownProps {
|
||||
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
|
||||
testId?: string;
|
||||
placeholder?: string;
|
||||
value?: AsyncSelectOption | null;
|
||||
defaultValue?: AsyncSelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isLoading?: boolean;
|
||||
cacheOptions?: boolean;
|
||||
defaultOptions?: boolean | AsyncSelectOption[];
|
||||
onChange?: (option: AsyncSelectOption | null) => void;
|
||||
onMenuScrollToBottom?: () => void;
|
||||
}
|
||||
|
||||
export function ReactSelectAsyncDropdown({
|
||||
loadOptions,
|
||||
testId,
|
||||
placeholder = "Search...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isLoading = false,
|
||||
cacheOptions = true,
|
||||
defaultOptions = true,
|
||||
onChange,
|
||||
onMenuScrollToBottom,
|
||||
}: ReactSelectAsyncDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
|
||||
|
||||
const handleLoadOptions = useCallback(
|
||||
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
|
||||
loadOptions(inputValue)
|
||||
.then((options) => callback(options))
|
||||
.catch(() => callback([]));
|
||||
},
|
||||
[loadOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid={testId} className={cn("w-full", className)}>
|
||||
<AsyncSelect
|
||||
loadOptions={handleLoadOptions}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isLoading={isLoading}
|
||||
cacheOptions={cacheOptions}
|
||||
defaultOptions={defaultOptions}
|
||||
onChange={onChange}
|
||||
onMenuScrollToBottom={onMenuScrollToBottom}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p
|
||||
data-testid="repo-dropdown-error"
|
||||
className="text-red-500 text-sm mt-1"
|
||||
>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/common/react-select-dropdown.tsx
Normal file
57
frontend/src/components/common/react-select-dropdown.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useMemo } from "react";
|
||||
import Select from "react-select";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
|
||||
|
||||
export type SelectOption = SelectOptionBase;
|
||||
|
||||
export interface ReactSelectDropdownProps {
|
||||
options: SelectOption[];
|
||||
placeholder?: string;
|
||||
value?: SelectOption | null;
|
||||
defaultValue?: SelectOption | null;
|
||||
className?: string;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
isClearable?: boolean;
|
||||
isSearchable?: boolean;
|
||||
isLoading?: boolean;
|
||||
onChange?: (option: SelectOption | null) => void;
|
||||
}
|
||||
|
||||
export function ReactSelectDropdown({
|
||||
options,
|
||||
placeholder = "Select option...",
|
||||
value,
|
||||
defaultValue,
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
isClearable = false,
|
||||
isSearchable = true,
|
||||
isLoading = false,
|
||||
onChange,
|
||||
}: ReactSelectDropdownProps) {
|
||||
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<Select
|
||||
options={options}
|
||||
value={value}
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
isDisabled={disabled}
|
||||
isClearable={isClearable}
|
||||
isSearchable={isSearchable}
|
||||
isLoading={isLoading}
|
||||
onChange={onChange}
|
||||
styles={customStyles}
|
||||
className="w-full"
|
||||
/>
|
||||
{errorMessage && (
|
||||
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
frontend/src/components/common/react-select-styles.ts
Normal file
92
frontend/src/components/common/react-select-styles.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { StylesConfig } from "react-select";
|
||||
|
||||
export interface SelectOptionBase {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
|
||||
T,
|
||||
false
|
||||
> => ({
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.125rem",
|
||||
minHeight: "2.5rem",
|
||||
padding: "0 0.5rem",
|
||||
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
|
||||
opacity: state.isDisabled ? 0.6 : 1,
|
||||
cursor: state.isDisabled ? "not-allowed" : "pointer",
|
||||
"&:hover": {
|
||||
borderColor: "#717888",
|
||||
},
|
||||
}),
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: "#ECEDEE", // content
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
fontStyle: "italic",
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
singleValue: (provided, state) => ({
|
||||
...provided,
|
||||
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
backgroundColor: "#454545", // tertiary
|
||||
border: "1px solid #717888",
|
||||
borderRadius: "0.75rem",
|
||||
overflow: "hidden", // ensure menu items don't overflow rounded corners
|
||||
}),
|
||||
menuList: (provided) => ({
|
||||
...provided,
|
||||
padding: "0.25rem", // add some padding around menu items
|
||||
}),
|
||||
option: (provided, state) => {
|
||||
let backgroundColor = "transparent";
|
||||
if (state.isSelected) {
|
||||
backgroundColor = "#C9B974"; // primary for selected
|
||||
} else if (state.isFocused) {
|
||||
backgroundColor = "#24272E"; // base-secondary for hover/focus
|
||||
}
|
||||
|
||||
return {
|
||||
...provided,
|
||||
backgroundColor,
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
|
||||
borderRadius: "0.5rem", // rounded menu items
|
||||
margin: "0.125rem 0", // small gap between items
|
||||
"&:hover": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
|
||||
},
|
||||
"&:active": {
|
||||
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
|
||||
color: state.isSelected ? "#000000" : "#ECEDEE",
|
||||
},
|
||||
};
|
||||
},
|
||||
clearIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
dropdownIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
"&:hover": {
|
||||
color: "#ECEDEE", // content
|
||||
},
|
||||
}),
|
||||
loadingIndicator: (provided) => ({
|
||||
...provided,
|
||||
color: "#B7BDC2", // tertiary-light
|
||||
}),
|
||||
});
|
||||
@@ -38,7 +38,7 @@ export function ActionSuggestions({
|
||||
pr,
|
||||
prShort,
|
||||
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Check your current branch name first - if it's main, master, deploy, or another common default branch name, create a new branch with a descriptive name related to your changes. Otherwise, use the exact SAME branch name as the one you are currently on.`,
|
||||
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. If you're on a default branch (e.g., main, master, deploy), create a new branch with a descriptive name otherwise use the current branch. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
|
||||
pushToPR: `Please push the latest changes to the existing ${pr}.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ChatMessage({
|
||||
className={cn(
|
||||
"rounded-xl relative w-fit",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "user" && " p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
@@ -86,7 +86,13 @@ export function ChatMessage({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words">
|
||||
<div
|
||||
className="text-sm"
|
||||
style={{
|
||||
whiteSpace: "normal",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
|
||||
@@ -77,25 +77,8 @@ const getMcpActionContent = (event: MCPAction): string => {
|
||||
const getThinkActionContent = (event: ThinkAction): string =>
|
||||
event.args.thought;
|
||||
|
||||
const getFinishActionContent = (event: FinishAction): string => {
|
||||
let content = event.args.final_thought;
|
||||
|
||||
switch (event.args.task_completed) {
|
||||
case "success":
|
||||
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_SUCCESSFULLY")}`;
|
||||
break;
|
||||
case "failure":
|
||||
content += `\n\n\n${i18n.t("FINISH$TASK_NOT_COMPLETED")}`;
|
||||
break;
|
||||
case "partial":
|
||||
default:
|
||||
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_PARTIALLY")}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
};
|
||||
|
||||
const getFinishActionContent = (event: FinishAction): string =>
|
||||
event.args.final_thought.trim();
|
||||
const getNoContentActionContent = (): string => "";
|
||||
|
||||
export const getActionContent = (event: OpenHandsAction): string => {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
|
||||
ref={ref}
|
||||
className="absolute right-full md:left-full -top-1 z-10 w-fit"
|
||||
>
|
||||
<ContextMenuListItem onClick={onLogout}>
|
||||
<ContextMenuListItem onClick={onLogout} data-testid="logout-button">
|
||||
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuIconTextProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
text: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuIconText({
|
||||
icon: Icon,
|
||||
text,
|
||||
className,
|
||||
iconClassName,
|
||||
}: ContextMenuIconTextProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3 px-1", className)}>
|
||||
<Icon className={cn("w-4 h-4 shrink-0", iconClassName)} />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function ContextMenuListItem({
|
||||
onClick={onClick}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
|
||||
"text-sm px-4 h-10 w-full text-start hover:bg-white/10 cursor-pointer",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ContextMenu({
|
||||
<ul
|
||||
data-testid={testId}
|
||||
ref={ref}
|
||||
className={cn("bg-tertiary rounded-md", className)}
|
||||
className={cn("bg-tertiary rounded-md overflow-hidden", className)}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import {
|
||||
Trash,
|
||||
Power,
|
||||
Pencil,
|
||||
Download,
|
||||
Wallet,
|
||||
Wrench,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ContextMenu } from "../context-menu/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuSeparator } from "../context-menu/context-menu-separator";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
|
||||
|
||||
interface ConversationCardContextMenuProps {
|
||||
onClose: () => void;
|
||||
@@ -31,6 +42,12 @@ export function ConversationCardContextMenu({
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const hasEdit = Boolean(onEdit);
|
||||
const hasDownload = Boolean(onDownloadViaVSCode);
|
||||
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
|
||||
const hasInfo = Boolean(onDisplayCost);
|
||||
const hasControl = Boolean(onStop || onDelete);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
@@ -41,51 +58,84 @@ export function ConversationCardContextMenu({
|
||||
position === "bottom" && "top-full",
|
||||
)}
|
||||
>
|
||||
{onDelete && (
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
{t(I18nKey.BUTTON$DELETE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
{t(I18nKey.BUTTON$STOP)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
{t(I18nKey.BUTTON$EDIT_TITLE)}
|
||||
<ContextMenuIconText
|
||||
icon={Pencil}
|
||||
text={t(I18nKey.BUTTON$EDIT_TITLE)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && (
|
||||
<ContextMenuSeparator />
|
||||
)}
|
||||
|
||||
{onDownloadViaVSCode && (
|
||||
<ContextMenuListItem
|
||||
testId="download-vscode-button"
|
||||
onClick={onDownloadViaVSCode}
|
||||
>
|
||||
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
|
||||
<ContextMenuIconText
|
||||
icon={Download}
|
||||
text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDisplayCost && (
|
||||
<ContextMenuListItem
|
||||
testId="display-cost-button"
|
||||
onClick={onDisplayCost}
|
||||
>
|
||||
{t(I18nKey.BUTTON$DISPLAY_COST)}
|
||||
</ContextMenuListItem>
|
||||
|
||||
{hasDownload && (hasTools || hasInfo || hasControl) && (
|
||||
<ContextMenuSeparator />
|
||||
)}
|
||||
|
||||
{onShowAgentTools && (
|
||||
<ContextMenuListItem
|
||||
testId="show-agent-tools-button"
|
||||
onClick={onShowAgentTools}
|
||||
>
|
||||
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
|
||||
<ContextMenuIconText
|
||||
icon={Wrench}
|
||||
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onShowMicroagents && (
|
||||
<ContextMenuListItem
|
||||
testId="show-microagents-button"
|
||||
onClick={onShowMicroagents}
|
||||
>
|
||||
{t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
|
||||
<ContextMenuIconText
|
||||
icon={Bot}
|
||||
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{hasTools && (hasInfo || hasControl) && <ContextMenuSeparator />}
|
||||
|
||||
{onDisplayCost && (
|
||||
<ContextMenuListItem
|
||||
testId="display-cost-button"
|
||||
onClick={onDisplayCost}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={Wallet}
|
||||
text={t(I18nKey.BUTTON$DISPLAY_COST)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{hasInfo && hasControl && <ContextMenuSeparator />}
|
||||
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
{onDelete && (
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
<ContextMenuIconText icon={Trash} text={t(I18nKey.BUTTON$DELETE)} />
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
</ContextMenu>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaInfoCircle } from "react-icons/fa";
|
||||
import { ConnectToProviderMessage } from "./connect-to-provider-message";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { RepoProviderLinks } from "./repo-provider-links";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface RepoConnectorProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -23,7 +25,19 @@ export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
|
||||
data-testid="repo-connector"
|
||||
className="w-full flex flex-col gap-6"
|
||||
>
|
||||
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
|
||||
<TooltipButton
|
||||
testId="repo-connector-info"
|
||||
tooltip={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
|
||||
ariaLabel={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
|
||||
className="text-[#9099AC] hover:text-white"
|
||||
placement="bottom"
|
||||
tooltipClassName="max-w-[348px]"
|
||||
>
|
||||
<FaInfoCircle size={16} />
|
||||
</TooltipButton>
|
||||
</div>
|
||||
|
||||
{!providersAreSet && <ConnectToProviderMessage />}
|
||||
{providersAreSet && (
|
||||
|
||||
@@ -2,22 +2,15 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import {
|
||||
RepositoryDropdown,
|
||||
RepositoryLoadingState,
|
||||
RepositoryErrorState,
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "./repository-selection";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
|
||||
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
|
||||
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -32,18 +25,11 @@ export function RepositorySelectionForm({
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
null,
|
||||
);
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = React.useRef<boolean>(false);
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
isError: isRepositoriesError,
|
||||
} = useUserRepositories();
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const [selectedProvider, setSelectedProvider] =
|
||||
React.useState<Provider | null>(null);
|
||||
const { providers } = useUserProviders();
|
||||
const { data: branches, isLoading: isLoadingBranches } =
|
||||
useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -52,151 +38,108 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversationElsewhere = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState("");
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
|
||||
|
||||
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
|
||||
// Auto-select provider if there's only one
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches &&
|
||||
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
}, [providers, selectedProvider]);
|
||||
|
||||
// We check for isSuccess because the app might require time to render
|
||||
// into the new conversation screen after the conversation is created.
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
const allRepositories = repositories?.concat(searchedRepos || []);
|
||||
const repositoriesItems = allRepositories?.map((repo) => ({
|
||||
key: repo.id,
|
||||
label: decodeURIComponent(repo.full_name),
|
||||
}));
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
setSelectedBranch(null); // Reset branch selection when repo changes
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null); // Reset repository selection when provider changes
|
||||
setSelectedBranch(null); // Reset branch selection when provider changes
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
// Reset the manually cleared flag when a branch is explicitly selected
|
||||
branchManuallyClearedRef.current = false;
|
||||
};
|
||||
|
||||
const handleRepoInputChange = (value: string) => {
|
||||
if (value === "") {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
onRepoSelection(null);
|
||||
} else if (value.startsWith("https://")) {
|
||||
const repoName = sanitizeQuery(value);
|
||||
setSearchQuery(repoName);
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBranchInputChange = (value: string) => {
|
||||
// Clear the selected branch if the input is empty or contains only whitespace
|
||||
// This fixes the issue where users can't delete the entire default branch name
|
||||
if (value === "" || value.trim() === "") {
|
||||
setSelectedBranch(null);
|
||||
// Set the flag to indicate that the branch was manually cleared
|
||||
branchManuallyClearedRef.current = true;
|
||||
} else {
|
||||
// Reset the flag when the user starts typing again
|
||||
branchManuallyClearedRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Render the appropriate UI based on the loading/error state
|
||||
const renderRepositorySelector = () => {
|
||||
if (isLoadingRepositories) {
|
||||
return <RepositoryLoadingState />;
|
||||
}
|
||||
|
||||
if (isRepositoriesError) {
|
||||
return <RepositoryErrorState />;
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
// Only render if there are multiple providers
|
||||
if (providers.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RepositoryDropdown
|
||||
items={repositoriesItems || []}
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleRepoInputChange}
|
||||
defaultFilter={(textValue, inputValue) => {
|
||||
if (!inputValue) return true;
|
||||
|
||||
const repo = allRepositories?.find((r) => r.full_name === textValue);
|
||||
if (!repo) return false;
|
||||
|
||||
const sanitizedInput = sanitizeQuery(inputValue);
|
||||
return sanitizeQuery(textValue).includes(sanitizedInput);
|
||||
}}
|
||||
<GitProviderDropdown
|
||||
providers={providers}
|
||||
value={selectedProvider}
|
||||
placeholder="Select Provider"
|
||||
className="max-w-[500px]"
|
||||
onChange={handleProviderSelection}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate UI for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
/>
|
||||
// Effect to auto-select main/master branch when branches are loaded
|
||||
React.useEffect(() => {
|
||||
if (branches?.length) {
|
||||
// Look for main or master branch
|
||||
const defaultBranch = branches.find(
|
||||
(branch) => branch.name === "main" || branch.name === "master",
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState />;
|
||||
// If found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState />;
|
||||
}
|
||||
// Render the repository selector using our new component
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
if (repository) {
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
<GitRepositoryDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
disabled={!selectedProvider}
|
||||
onChange={handleRepoSelection}
|
||||
className="max-w-[500px]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the branch selector
|
||||
const renderBranchSelector = () => (
|
||||
<GitBranchDropdown
|
||||
repositoryName={selectedRepository?.full_name}
|
||||
value={selectedBranch?.name || null}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
onChange={handleBranchSelection}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderProviderSelector()}
|
||||
{renderRepositorySelector()}
|
||||
|
||||
{renderBranchSelector()}
|
||||
|
||||
<BrandButton
|
||||
@@ -205,9 +148,10 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
isCreatingConversation ||
|
||||
isLoadingRepositories ||
|
||||
isRepositoriesError
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
@@ -215,7 +159,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
|
||||
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
|
||||
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
|
||||
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
|
||||
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
|
||||
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export interface RepositoryDropdownProps {
|
||||
items: { key: React.Key; label: string }[];
|
||||
onSelectionChange: (key: React.Key | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
}
|
||||
|
||||
export function RepositoryDropdown({
|
||||
items,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: RepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
items={items}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
defaultFilter={defaultFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function RepositoryErrorState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-error"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
|
||||
>
|
||||
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
|
||||
export function RepositoryLoadingState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="repo-dropdown-loading"
|
||||
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
|
||||
>
|
||||
<Spinner size="sm" />
|
||||
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
import {
|
||||
@@ -38,22 +37,6 @@ export function MicroagentManagementMicroagentCard({
|
||||
pr_number: prNumber,
|
||||
} = conversation ?? {};
|
||||
|
||||
// Format the repository URL to point to the microagent file
|
||||
const microagentFilePath = microagent
|
||||
? `.openhands/microagents/${microagent.name}`
|
||||
: "";
|
||||
|
||||
// Format the createdAt date using MM/DD/YYYY format
|
||||
const formattedCreatedAt = useMemo(() => {
|
||||
if (microagent) {
|
||||
return formatDateMMDDYYYY(new Date(microagent.created_at));
|
||||
}
|
||||
if (conversation) {
|
||||
return formatDateMMDDYYYY(new Date(conversation.created_at));
|
||||
}
|
||||
return "";
|
||||
}, [microagent, conversation]);
|
||||
|
||||
const hasPr = !!(prNumber && prNumber.length > 0);
|
||||
|
||||
// Helper function to get status text
|
||||
@@ -131,12 +114,9 @@ export function MicroagentManagementMicroagentCard({
|
||||
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
|
||||
{!!microagent && (
|
||||
<div className="text-white text-sm font-normal">
|
||||
{microagentFilePath}
|
||||
{microagent.path}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-white text-sm font-normal">
|
||||
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
|
||||
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
@@ -22,15 +23,21 @@ export function MicroagentManagementSidebar({
|
||||
}: MicroagentManagementSidebarProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: repositories, isLoading } = useUserRepositories();
|
||||
const { providers } = useUserProviders();
|
||||
const selectedProvider = providers.length > 0 ? providers[0] : null;
|
||||
const { data: repositories, isLoading } =
|
||||
useUserRepositories(selectedProvider);
|
||||
|
||||
useEffect(() => {
|
||||
if (repositories) {
|
||||
if (repositories?.pages) {
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
repositories.forEach((repo: GitRepository) => {
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
allRepositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
|
||||
@@ -8,11 +8,12 @@ import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { cn, extractRepositoryInfo } from "#/utils/utils";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { MicroagentFormData } from "#/types/microagent-management";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
@@ -51,13 +52,23 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = useRef<boolean>(false);
|
||||
|
||||
// Extract owner and repo from full_name for content API
|
||||
const { owner, repo, filePath } = extractRepositoryInfo(
|
||||
selectedRepository,
|
||||
microagent,
|
||||
);
|
||||
|
||||
// Fetch microagent content when updating
|
||||
const { data: microagentContentData, isLoading: isLoadingContent } =
|
||||
useRepositoryMicroagentContent(owner, repo, filePath, true);
|
||||
|
||||
// Populate form fields with existing microagent data when updating
|
||||
useEffect(() => {
|
||||
if (isUpdate && microagent) {
|
||||
setQuery(microagent.content);
|
||||
setTriggers(microagent.triggers || []);
|
||||
if (isUpdate && microagentContentData) {
|
||||
setQuery(microagentContentData.content);
|
||||
setTriggers(microagentContentData.triggers || []);
|
||||
}
|
||||
}, [isUpdate, microagent]);
|
||||
}, [isUpdate, microagentContentData]);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
@@ -294,10 +305,11 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError
|
||||
isBranchesError ||
|
||||
(isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
}
|
||||
>
|
||||
{isLoading || isLoadingBranches
|
||||
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useSelector } from "react-redux";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
@@ -7,8 +9,12 @@ import { ul, ol } from "../markdown/list";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { RootState } from "#/store";
|
||||
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { extractRepositoryInfo } from "#/utils/utils";
|
||||
|
||||
export function MicroagentManagementViewMicroagentContent() {
|
||||
const { t } = useTranslation();
|
||||
const { selectedMicroagentItem } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
@@ -19,55 +25,49 @@ export function MicroagentManagementViewMicroagentContent() {
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
const transformMicroagentContent = (): string => {
|
||||
if (!microagent) {
|
||||
return "";
|
||||
}
|
||||
// Extract owner and repo from full_name (e.g., "owner/repo")
|
||||
const { owner, repo, filePath } = extractRepositoryInfo(
|
||||
selectedRepository,
|
||||
microagent,
|
||||
);
|
||||
|
||||
// If no triggers exist, return the content as-is
|
||||
if (!microagent.triggers || microagent.triggers.length === 0) {
|
||||
return microagent.content;
|
||||
}
|
||||
|
||||
// Create the triggers frontmatter
|
||||
const triggersFrontmatter = `
|
||||
---
|
||||
|
||||
triggers:
|
||||
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
|
||||
|
||||
---
|
||||
`;
|
||||
|
||||
// Prepend the frontmatter to the content
|
||||
return `
|
||||
${triggersFrontmatter}
|
||||
|
||||
${microagent.content}
|
||||
`;
|
||||
};
|
||||
// Fetch microagent content using the new API
|
||||
const {
|
||||
data: microagentData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRepositoryMicroagentContent(owner, repo, filePath, true);
|
||||
|
||||
if (!microagent || !selectedRepository) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform the content to include triggers frontmatter if applicable
|
||||
const transformedContent = transformMicroagentContent();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{transformedContent}
|
||||
</Markdown>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<Spinner size="lg" data-testid="loading-microagent-content-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT)}
|
||||
</div>
|
||||
)}
|
||||
{microagentData && !isLoading && !error && (
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{microagentData.content}
|
||||
</Markdown>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,16 +12,21 @@ export function ConfigureGitHubRepositoriesAnchor({
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="configure-github-repositories-button"
|
||||
href={`https://github.com/apps/${slug}/installations/new`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
<div data-testid="configure-github-repositories-button" className="py-9">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-55"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://github.com/apps/${slug}/installations/new`,
|
||||
"_blank",
|
||||
"noreferrer noopener",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,21 @@ export function InstallSlackAppAnchor() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="install-slack-app-button"
|
||||
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="py-9"
|
||||
>
|
||||
<BrandButton type="button" variant="secondary">
|
||||
<div data-testid="install-slack-app-button" className="py-9">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="w-55"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope=",
|
||||
"_blank",
|
||||
"noreferrer noopener",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t(I18nKey.SLACK$INSTALL_APP)}
|
||||
</BrandButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import React, { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
BaseModalTitle,
|
||||
} from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
|
||||
import { useValidateIntegration } from "#/hooks/mutation/use-validate-integration";
|
||||
|
||||
interface ConfigureButtonProps {
|
||||
onClick: () => void;
|
||||
isDisabled: boolean;
|
||||
text?: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function ConfigureButton({
|
||||
onClick,
|
||||
isDisabled,
|
||||
text,
|
||||
"data-testid": dataTestId,
|
||||
}: ConfigureButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<BrandButton
|
||||
data-testid={dataTestId}
|
||||
variant="primary"
|
||||
onClick={onClick}
|
||||
isDisabled={isDisabled}
|
||||
type="button"
|
||||
className="w-30 min-w-20"
|
||||
>
|
||||
{text || t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL)}
|
||||
</BrandButton>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConfigureModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (data: {
|
||||
workspace: string;
|
||||
webhookSecret: string;
|
||||
serviceAccountEmail: string;
|
||||
serviceAccountApiKey: string;
|
||||
isActive: boolean;
|
||||
}) => void;
|
||||
onLink: (workspace: string) => void;
|
||||
onUnlink?: () => void;
|
||||
platformName: string;
|
||||
platform: "jira" | "jira-dc" | "linear";
|
||||
integrationData?: {
|
||||
id: number;
|
||||
keycloak_user_id: string;
|
||||
status: string;
|
||||
workspace?: {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
editable: boolean;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ConfigureModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
onLink,
|
||||
onUnlink,
|
||||
platformName,
|
||||
platform,
|
||||
integrationData,
|
||||
}: ConfigureModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspace, setWorkspace] = useState("");
|
||||
const [webhookSecret, setWebhookSecret] = useState("");
|
||||
const [serviceAccountEmail, setServiceAccountEmail] = useState("");
|
||||
const [serviceAccountApiKey, setServiceAccountApiKey] = useState("");
|
||||
const [isActive, setIsActive] = useState(true);
|
||||
const [showConfigurationFields, setShowConfigurationFields] = useState(false);
|
||||
|
||||
// Determine initial state based on integrationData
|
||||
const existingWorkspace = integrationData?.workspace;
|
||||
const isWorkspaceEditable = existingWorkspace?.editable ?? false;
|
||||
|
||||
// Set initial workspace value when modal opens
|
||||
React.useEffect(() => {
|
||||
if (isOpen && existingWorkspace) {
|
||||
setWorkspace(existingWorkspace.name);
|
||||
setShowConfigurationFields(isWorkspaceEditable);
|
||||
} else if (isOpen && !existingWorkspace) {
|
||||
setWorkspace("");
|
||||
setShowConfigurationFields(false);
|
||||
}
|
||||
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
|
||||
|
||||
// Validation states
|
||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
|
||||
|
||||
const validateMutation = useValidateIntegration(platform, {
|
||||
onSuccess: (data) => {
|
||||
if (data.data.status === "active") {
|
||||
// Validation successful, proceed with linking
|
||||
onLink(workspace.trim());
|
||||
} else {
|
||||
// Show configuration fields for further setup
|
||||
setShowConfigurationFields(true);
|
||||
setIsActive(true);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.response?.status === 404) {
|
||||
// Integration not found, show configuration fields
|
||||
setShowConfigurationFields(true);
|
||||
setIsActive(true);
|
||||
} else {
|
||||
// Other errors - still show configuration fields as fallback
|
||||
setShowConfigurationFields(true);
|
||||
setIsActive(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Validation functions
|
||||
const validateWorkspace = (value: string) => {
|
||||
const isValid = /^[a-zA-Z0-9\-_.]*$/.test(value);
|
||||
if (!isValid && value.length > 0) {
|
||||
setWorkspaceError(
|
||||
t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR),
|
||||
);
|
||||
} else {
|
||||
setWorkspaceError(null);
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const validateWebhookSecret = (value: string) => {
|
||||
const hasSpaces = /\s/.test(value);
|
||||
if (hasSpaces) {
|
||||
setWebhookSecretError(
|
||||
t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR),
|
||||
);
|
||||
} else {
|
||||
setWebhookSecretError(null);
|
||||
}
|
||||
return !hasSpaces;
|
||||
};
|
||||
|
||||
const validateEmail = (value: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const isValid = emailRegex.test(value) || value.length === 0;
|
||||
if (!isValid && value.length > 0) {
|
||||
setEmailError(
|
||||
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR),
|
||||
);
|
||||
} else {
|
||||
setEmailError(null);
|
||||
}
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const validateApiKey = (value: string) => {
|
||||
const hasSpaces = /\s/.test(value);
|
||||
if (hasSpaces) {
|
||||
setApiKeyError(
|
||||
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR),
|
||||
);
|
||||
} else {
|
||||
setApiKeyError(null);
|
||||
}
|
||||
return !hasSpaces;
|
||||
};
|
||||
|
||||
// Input handlers with validation
|
||||
const handleWorkspaceChange = (value: string) => {
|
||||
setWorkspace(value);
|
||||
validateWorkspace(value);
|
||||
};
|
||||
|
||||
const handleWebhookSecretChange = (value: string) => {
|
||||
setWebhookSecret(value);
|
||||
validateWebhookSecret(value);
|
||||
};
|
||||
|
||||
const handleEmailChange = (value: string) => {
|
||||
setServiceAccountEmail(value);
|
||||
validateEmail(value);
|
||||
};
|
||||
|
||||
const handleApiKeyChange = (value: string) => {
|
||||
setServiceAccountApiKey(value);
|
||||
validateApiKey(value);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setWorkspace("");
|
||||
setWebhookSecret("");
|
||||
setServiceAccountEmail("");
|
||||
setServiceAccountApiKey("");
|
||||
setIsActive(false);
|
||||
setShowConfigurationFields(false);
|
||||
setWorkspaceError(null);
|
||||
setWebhookSecretError(null);
|
||||
setEmailError(null);
|
||||
setApiKeyError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleConnect = () => {
|
||||
if (showConfigurationFields) {
|
||||
// Full configuration flow (either new configuration or editing existing)
|
||||
onConfirm({
|
||||
workspace,
|
||||
webhookSecret,
|
||||
serviceAccountEmail,
|
||||
serviceAccountApiKey,
|
||||
isActive,
|
||||
});
|
||||
} else if (!existingWorkspace) {
|
||||
// First check the workspace with validation for new integrations
|
||||
validateMutation.mutate(workspace.trim());
|
||||
}
|
||||
// For existing workspace that's not editable, no action needed
|
||||
// This case shouldn't happen as the button should be hidden
|
||||
};
|
||||
|
||||
const isConnectDisabled = showConfigurationFields
|
||||
? !workspace.trim() ||
|
||||
!webhookSecret.trim() ||
|
||||
!serviceAccountEmail.trim() ||
|
||||
!serviceAccountApiKey.trim() ||
|
||||
workspaceError !== null ||
|
||||
webhookSecretError !== null ||
|
||||
emailError !== null ||
|
||||
apiKeyError !== null ||
|
||||
validateMutation.isPending
|
||||
: !workspace.trim() ||
|
||||
workspaceError !== null ||
|
||||
validateMutation.isPending;
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={handleClose}>
|
||||
<ModalBody className="items-start border border-tertiary w-96">
|
||||
<BaseModalTitle
|
||||
title={
|
||||
showConfigurationFields
|
||||
? t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE, {
|
||||
platform: platformName,
|
||||
})
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE)
|
||||
}
|
||||
/>
|
||||
<BaseModalDescription>
|
||||
{showConfigurationFields ? (
|
||||
<Trans
|
||||
i18nKey={I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
Check the document for more information
|
||||
</a>
|
||||
),
|
||||
b: <b />,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<p className="mt-4">
|
||||
<Trans
|
||||
i18nKey={
|
||||
I18nKey.PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION
|
||||
}
|
||||
components={{
|
||||
b: <b />,
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 underline"
|
||||
>
|
||||
Check the document for more information
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
</BaseModalDescription>
|
||||
<div className="w-full flex flex-col gap-4 mt-4">
|
||||
<div>
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<SettingsInput
|
||||
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
|
||||
placeholder={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER,
|
||||
)}
|
||||
value={workspace}
|
||||
onChange={handleWorkspaceChange}
|
||||
className="w-full"
|
||||
type="text"
|
||||
pattern="^[a-zA-Z0-9\-_.]*$"
|
||||
isDisabled={!!existingWorkspace}
|
||||
/>
|
||||
</div>
|
||||
{existingWorkspace && onUnlink && (
|
||||
<BrandButton
|
||||
variant="secondary"
|
||||
onClick={onUnlink}
|
||||
data-testid="unlink-button"
|
||||
type="button"
|
||||
className="mb-0"
|
||||
>
|
||||
{t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
{workspaceError && (
|
||||
<p className="text-red-500 text-sm mt-2">{workspaceError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showConfigurationFields && (
|
||||
<>
|
||||
<div>
|
||||
<SettingsInput
|
||||
label={t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL)}
|
||||
placeholder={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER,
|
||||
)}
|
||||
value={webhookSecret}
|
||||
onChange={handleWebhookSecretChange}
|
||||
className="w-full"
|
||||
type="password"
|
||||
/>
|
||||
{webhookSecretError && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
{webhookSecretError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SettingsInput
|
||||
label={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL,
|
||||
)}
|
||||
placeholder={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER,
|
||||
)}
|
||||
value={serviceAccountEmail}
|
||||
onChange={handleEmailChange}
|
||||
className="w-full"
|
||||
type="email"
|
||||
/>
|
||||
{emailError && (
|
||||
<p className="text-red-500 text-sm mt-2">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<SettingsInput
|
||||
label={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL,
|
||||
)}
|
||||
placeholder={t(
|
||||
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER,
|
||||
)}
|
||||
value={serviceAccountApiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
className="w-full"
|
||||
type="password"
|
||||
/>
|
||||
{apiKeyError && (
|
||||
<p className="text-red-500 text-sm mt-2">{apiKeyError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<SettingsSwitch
|
||||
testId="active-toggle"
|
||||
onToggle={setIsActive}
|
||||
isToggled={isActive}
|
||||
>
|
||||
{t(I18nKey.PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL)}
|
||||
</SettingsSwitch>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full mt-4">
|
||||
{/* Hide the connect/edit button if workspace exists but is not editable */}
|
||||
{(!existingWorkspace || isWorkspaceEditable) && (
|
||||
<BrandButton
|
||||
variant="primary"
|
||||
onClick={handleConnect}
|
||||
data-testid="connect-button"
|
||||
type="button"
|
||||
className="w-full"
|
||||
isDisabled={isConnectDisabled}
|
||||
>
|
||||
{(() => {
|
||||
if (existingWorkspace && showConfigurationFields) {
|
||||
return t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL);
|
||||
}
|
||||
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
|
||||
})()}
|
||||
</BrandButton>
|
||||
)}
|
||||
<BrandButton
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
data-testid="cancel-button"
|
||||
type="button"
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface IntegrationButtonProps {
|
||||
isLoading: boolean;
|
||||
isLinked: boolean;
|
||||
onClick: () => void;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function IntegrationButton({
|
||||
isLoading,
|
||||
isLinked,
|
||||
onClick,
|
||||
"data-testid": dataTestId,
|
||||
}: IntegrationButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<BrandButton
|
||||
data-testid={dataTestId}
|
||||
variant={isLinked ? "secondary" : "primary"}
|
||||
onClick={onClick}
|
||||
isDisabled={isLoading}
|
||||
type="button"
|
||||
className="w-30 min-w-20"
|
||||
>
|
||||
{isLoading && t(I18nKey.SETTINGS$SAVING)}
|
||||
{!isLoading &&
|
||||
(isLinked
|
||||
? t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$LINK_BUTTON_LABEL))}
|
||||
</BrandButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useIntegrationStatus } from "#/hooks/query/use-integration-status";
|
||||
import { useLinkIntegration } from "#/hooks/mutation/use-link-integration";
|
||||
import { useUnlinkIntegration } from "#/hooks/mutation/use-unlink-integration";
|
||||
import { useConfigureIntegration } from "#/hooks/mutation/use-configure-integration";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
ConfigureButton,
|
||||
ConfigureModal,
|
||||
} from "#/components/features/settings/project-management/configure-modal";
|
||||
|
||||
interface IntegrationRowProps {
|
||||
platform: "jira" | "jira-dc" | "linear";
|
||||
platformName: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function IntegrationRow({
|
||||
platform,
|
||||
platformName,
|
||||
"data-testid": dataTestId,
|
||||
}: IntegrationRowProps) {
|
||||
const [isConfigureModalOpen, setConfigureModalOpen] = React.useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: integrationData, isLoading: isStatusLoading } =
|
||||
useIntegrationStatus(platform);
|
||||
|
||||
const linkMutation = useLinkIntegration(platform, {
|
||||
onSettled: () => {
|
||||
setConfigureModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const unlinkMutation = useUnlinkIntegration(platform, {
|
||||
onSettled: () => {
|
||||
setConfigureModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const configureMutation = useConfigureIntegration(platform, {
|
||||
onSettled: () => {
|
||||
setConfigureModalOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleConfigure = () => {
|
||||
setConfigureModalOpen(true);
|
||||
};
|
||||
|
||||
const handleLink = (workspace: string) => {
|
||||
linkMutation.mutate(workspace);
|
||||
};
|
||||
|
||||
const handleUnlink = () => {
|
||||
unlinkMutation.mutate();
|
||||
};
|
||||
|
||||
const handleConfigureConfirm = (data: {
|
||||
workspace: string;
|
||||
webhookSecret: string;
|
||||
serviceAccountEmail: string;
|
||||
serviceAccountApiKey: string;
|
||||
isActive: boolean;
|
||||
}) => {
|
||||
configureMutation.mutate(data);
|
||||
};
|
||||
|
||||
const isLoading =
|
||||
isStatusLoading ||
|
||||
linkMutation.isPending ||
|
||||
unlinkMutation.isPending ||
|
||||
configureMutation.isPending;
|
||||
|
||||
// Determine if integration is active and workspace exists
|
||||
const isIntegrationActive = integrationData?.status === "active";
|
||||
const hasWorkspace = integrationData?.workspace;
|
||||
|
||||
// Determine button text based on integration state
|
||||
const buttonText =
|
||||
isIntegrationActive && hasWorkspace
|
||||
? t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL)
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between" data-testid={dataTestId}>
|
||||
<span className="font-medium">{platformName}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<ConfigureButton
|
||||
onClick={handleConfigure}
|
||||
isDisabled={isLoading}
|
||||
text={buttonText}
|
||||
data-testid={`${platform}-configure-button`}
|
||||
/>
|
||||
</div>
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => setConfigureModalOpen(false)}
|
||||
onConfirm={handleConfigureConfirm}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
platformName={platformName}
|
||||
platform={platform}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { IntegrationRow } from "./integration-row";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
export function ProjectManagementIntegration() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-1/4">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
{t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{config?.FEATURE_FLAGS?.ENABLE_JIRA && (
|
||||
<IntegrationRow
|
||||
platform="jira"
|
||||
platformName="Jira Cloud"
|
||||
data-testid="jira-integration-row"
|
||||
/>
|
||||
)}
|
||||
{config?.FEATURE_FLAGS?.ENABLE_JIRA_DC && (
|
||||
<IntegrationRow
|
||||
platform="jira-dc"
|
||||
platformName="Jira Data Center"
|
||||
data-testid="jira-dc-integration-row"
|
||||
/>
|
||||
)}
|
||||
{config?.FEATURE_FLAGS?.ENABLE_LINEAR && (
|
||||
<IntegrationRow
|
||||
platform="linear"
|
||||
platformName="Linear"
|
||||
data-testid="linear-integration-row"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { ReactNode } from "react";
|
||||
import React, { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
@@ -44,6 +44,7 @@ export function SettingsDropdownInput({
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
|
||||
interface UserActionsProps {
|
||||
onLogout: () => void;
|
||||
@@ -12,7 +13,11 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
|
||||
React.useState(false);
|
||||
|
||||
// Use the shared hook to determine if user actions should be shown
|
||||
const shouldShowUserActions = useShouldShowUserFeatures();
|
||||
|
||||
const toggleAccountMenu = () => {
|
||||
// Always toggle the menu, even if user is undefined
|
||||
setAccountContextMenuIsVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
@@ -25,6 +30,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
closeAccountMenu();
|
||||
};
|
||||
|
||||
// Show the menu based on the new logic
|
||||
const showMenu = accountContextMenuIsVisible && shouldShowUserActions;
|
||||
|
||||
return (
|
||||
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
|
||||
<UserAvatar
|
||||
@@ -33,7 +41,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{accountContextMenuIsVisible && !!user && (
|
||||
{showMenu && (
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UserAvatarProps {
|
||||
|
||||
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TooltipButton
|
||||
testId="user-avatar"
|
||||
|
||||
@@ -15,12 +15,14 @@ import { Provider } from "#/types/settings";
|
||||
interface AuthModalProps {
|
||||
githubAuthUrl: string | null;
|
||||
appMode?: GetConfigResponse["APP_MODE"] | null;
|
||||
authUrl?: GetConfigResponse["AUTH_URL"];
|
||||
providersConfigured?: Provider[];
|
||||
}
|
||||
|
||||
export function AuthModal({
|
||||
githubAuthUrl,
|
||||
appMode,
|
||||
authUrl,
|
||||
providersConfigured,
|
||||
}: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -28,11 +30,19 @@ export function AuthModal({
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "gitlab",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const bitbucketAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "bitbucket",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const enterpriseSsoUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
identityProvider: "enterprise_sso",
|
||||
authUrl,
|
||||
});
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
@@ -56,6 +66,13 @@ export function AuthModal({
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoUrl) {
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = enterpriseSsoUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Only show buttons if providers are configured and include the specific provider
|
||||
const showGithub =
|
||||
providersConfigured &&
|
||||
@@ -69,6 +86,10 @@ export function AuthModal({
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("bitbucket");
|
||||
const showEnterpriseSso =
|
||||
providersConfigured &&
|
||||
providersConfigured.length > 0 &&
|
||||
providersConfigured.includes("enterprise_sso");
|
||||
|
||||
// Check if no providers are configured
|
||||
const noProvidersConfigured =
|
||||
@@ -126,6 +147,17 @@ export function AuthModal({
|
||||
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
|
||||
</BrandButton>
|
||||
)}
|
||||
|
||||
{showEnterpriseSso && (
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleEnterpriseSsoAuth}
|
||||
className="w-full"
|
||||
>
|
||||
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ interface ConversationSubscriptionsContextType {
|
||||
subscribeToConversation: (options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => void;
|
||||
@@ -135,7 +135,7 @@ export function ConversationSubscriptionsProvider({
|
||||
(options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
@@ -226,6 +226,7 @@ export function ConversationSubscriptionsProvider({
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
||||
error,
|
||||
@@ -233,6 +234,7 @@ export function ConversationSubscriptionsProvider({
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
||||
reason,
|
||||
|
||||
@@ -67,9 +67,9 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
<div id="modal-portal-exit" />
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
72
frontend/src/hooks/mutation/use-configure-integration.ts
Normal file
72
frontend/src/hooks/mutation/use-configure-integration.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
interface ConfigureIntegrationData {
|
||||
workspace: string;
|
||||
webhookSecret: string;
|
||||
serviceAccountEmail: string;
|
||||
serviceAccountApiKey: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function useConfigureIntegration(
|
||||
platform: "jira" | "jira-dc" | "linear",
|
||||
{
|
||||
onSettled,
|
||||
}: {
|
||||
onSettled: () => void;
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: ConfigureIntegrationData) => {
|
||||
const input = {
|
||||
workspace_name: data.workspace,
|
||||
webhook_secret: data.webhookSecret,
|
||||
svc_acc_email: data.serviceAccountEmail,
|
||||
svc_acc_api_key: data.serviceAccountApiKey,
|
||||
is_active: data.isActive,
|
||||
};
|
||||
|
||||
const response = await openHands.post(
|
||||
`/integration/${platform}/workspaces`,
|
||||
input,
|
||||
);
|
||||
|
||||
const { success, redirect, authorizationUrl } = response.data;
|
||||
|
||||
if (success) {
|
||||
if (redirect) {
|
||||
if (authorizationUrl) {
|
||||
window.location.href = authorizationUrl;
|
||||
} else {
|
||||
throw new Error("Could not get authorization URL from the server.");
|
||||
}
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Configuration failed");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["integration-status", platform],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled,
|
||||
});
|
||||
}
|
||||
60
frontend/src/hooks/mutation/use-link-integration.ts
Normal file
60
frontend/src/hooks/mutation/use-link-integration.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export function useLinkIntegration(
|
||||
platform: "jira" | "jira-dc" | "linear",
|
||||
{
|
||||
onSettled,
|
||||
}: {
|
||||
onSettled: () => void;
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (workspace: string) => {
|
||||
const input = {
|
||||
workspace_name: workspace,
|
||||
};
|
||||
|
||||
const response = await openHands.post(
|
||||
`/integration/${platform}/workspaces/link`,
|
||||
input,
|
||||
);
|
||||
|
||||
const { success, redirect, authorizationUrl } = response.data;
|
||||
|
||||
if (success) {
|
||||
if (redirect) {
|
||||
if (authorizationUrl) {
|
||||
window.location.href = authorizationUrl;
|
||||
} else {
|
||||
throw new Error("Could not get authorization URL from the server.");
|
||||
}
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Link integration failed");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["integration-status", platform],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled,
|
||||
});
|
||||
}
|
||||
@@ -27,6 +27,10 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
|
||||
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
|
||||
git_user_name:
|
||||
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
|
||||
git_user_email:
|
||||
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
|
||||
};
|
||||
|
||||
await OpenHands.saveSettings(apiSettings);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import {
|
||||
BatchFeedbackData,
|
||||
getFeedbackQueryKey,
|
||||
} from "../query/use-batch-feedback";
|
||||
|
||||
type SubmitConversationFeedbackArgs = {
|
||||
rating: number;
|
||||
@@ -12,7 +15,6 @@ type SubmitConversationFeedbackArgs = {
|
||||
export const useSubmitConversationFeedback = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ rating, eventId, reason }: SubmitConversationFeedbackArgs) =>
|
||||
@@ -22,18 +24,56 @@ export const useSubmitConversationFeedback = () => {
|
||||
eventId,
|
||||
reason,
|
||||
),
|
||||
onSuccess: (_, { eventId }) => {
|
||||
// Invalidate the feedback existence query to trigger a refetch
|
||||
onMutate: async ({ rating, eventId, reason }) => {
|
||||
if (!eventId) return { previousFeedback: null };
|
||||
|
||||
// Get the query key for the feedback data
|
||||
const queryKey = getFeedbackQueryKey(conversationId);
|
||||
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousFeedback =
|
||||
queryClient.getQueryData<Record<string, BatchFeedbackData>>(queryKey);
|
||||
|
||||
// Optimistically update the cache
|
||||
queryClient.setQueryData<Record<string, BatchFeedbackData>>(
|
||||
queryKey,
|
||||
(old = {}) => {
|
||||
const newData = { ...old };
|
||||
newData[eventId.toString()] = {
|
||||
exists: true,
|
||||
rating,
|
||||
reason,
|
||||
metadata: { source: "likert-scale" },
|
||||
};
|
||||
return newData;
|
||||
},
|
||||
);
|
||||
|
||||
// Return a context object with the snapshotted value
|
||||
return { previousFeedback };
|
||||
},
|
||||
onError: (error, { eventId }, context) => {
|
||||
// Roll back to the previous value on error
|
||||
if (context?.previousFeedback && eventId) {
|
||||
queryClient.setQueryData(
|
||||
getFeedbackQueryKey(conversationId),
|
||||
context.previousFeedback,
|
||||
);
|
||||
}
|
||||
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
},
|
||||
onSettled: (_, __, { eventId }) => {
|
||||
if (eventId) {
|
||||
// Invalidate both the old and new query keys to ensure consistency
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["feedback", "exists", conversationId, eventId],
|
||||
queryKey: getFeedbackQueryKey(conversationId),
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(t("FEEDBACK$FAILED_TO_SUBMIT"), error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
38
frontend/src/hooks/mutation/use-unlink-integration.ts
Normal file
38
frontend/src/hooks/mutation/use-unlink-integration.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import {
|
||||
displayErrorToast,
|
||||
displaySuccessToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export function useUnlinkIntegration(
|
||||
platform: "jira" | "jira-dc" | "linear",
|
||||
{
|
||||
onSettled,
|
||||
}: {
|
||||
onSettled: () => void;
|
||||
},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () =>
|
||||
openHands.post(`/integration/${platform}/workspaces/unlink`),
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["integration-status", platform],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
onSettled,
|
||||
});
|
||||
}
|
||||
43
frontend/src/hooks/mutation/use-validate-integration.ts
Normal file
43
frontend/src/hooks/mutation/use-validate-integration.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export function useValidateIntegration(
|
||||
platform: "jira" | "jira-dc" | "linear",
|
||||
{
|
||||
onSuccess,
|
||||
onError,
|
||||
}: {
|
||||
onSuccess: (data: any) => void;
|
||||
onError: (error: any) => void;
|
||||
},
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (workspace?: string) => {
|
||||
const workspaceParam = workspace ? `/${workspace}` : "";
|
||||
return openHands.get(
|
||||
`/integration/${platform}/workspaces/validate${workspaceParam}`,
|
||||
);
|
||||
},
|
||||
onSuccess,
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
onError(error);
|
||||
} else {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(
|
||||
errorMessage ||
|
||||
t(I18nKey.PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
24
frontend/src/hooks/query/use-app-installations.ts
Normal file
24
frontend/src/hooks/query/use-app-installations.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
export const useAppInstallations = (selectedProvider: Provider | null) => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providers || [], selectedProvider],
|
||||
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
!!selectedProvider &&
|
||||
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
52
frontend/src/hooks/query/use-batch-feedback.ts
Normal file
52
frontend/src/hooks/query/use-batch-feedback.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
|
||||
export interface BatchFeedbackData {
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Query key factory to ensure consistency across hooks
|
||||
export const getFeedbackQueryKey = (conversationId?: string) =>
|
||||
["feedback", "data", conversationId] as const;
|
||||
|
||||
// Query key factory for individual feedback existence
|
||||
export const getFeedbackExistsQueryKey = (
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
) => ["feedback", "exists", conversationId, eventId] as const;
|
||||
|
||||
export const useBatchFeedback = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: getFeedbackQueryKey(conversationId),
|
||||
queryFn: () => OpenHands.getBatchFeedback(conversationId!),
|
||||
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
// Update individual feedback cache entries when batch data changes
|
||||
React.useEffect(() => {
|
||||
if (query.data && conversationId) {
|
||||
Object.entries(query.data).forEach(([eventId, feedback]) => {
|
||||
queryClient.setQueryData(
|
||||
getFeedbackExistsQueryKey(conversationId, parseInt(eventId, 10)),
|
||||
feedback,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [query.data, conversationId, queryClient]);
|
||||
|
||||
return query;
|
||||
};
|
||||
@@ -1,25 +1,28 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
|
||||
|
||||
export interface FeedbackData {
|
||||
exists: boolean;
|
||||
rating?: number;
|
||||
reason?: string;
|
||||
}
|
||||
export type FeedbackData = BatchFeedbackData;
|
||||
|
||||
export const useFeedbackExists = (eventId?: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return useQuery<FeedbackData>({
|
||||
queryKey: ["feedback", "exists", conversationId, eventId],
|
||||
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
|
||||
queryFn: () => {
|
||||
if (!eventId) return { exists: false };
|
||||
return OpenHands.checkFeedbackExists(conversationId, eventId);
|
||||
|
||||
// Try to get the data from the batch cache
|
||||
const batchData = queryClient.getQueryData<
|
||||
Record<string, BatchFeedbackData>
|
||||
>(getFeedbackQueryKey(conversationId));
|
||||
|
||||
return batchData?.[eventId.toString()] ?? { exists: false };
|
||||
},
|
||||
enabled: !!eventId && config?.APP_MODE === "saas",
|
||||
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
|
||||
export const useGetSecrets = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { providers } = useUserProviders();
|
||||
const { data: isAuthed } = useIsAuthed();
|
||||
|
||||
const isOss = config?.APP_MODE === "oss";
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["secrets"],
|
||||
queryFn: SecretsService.getSecrets,
|
||||
enabled: isOss || providers.length > 0,
|
||||
enabled: isOss || isAuthed, // Enable regardless of providers
|
||||
});
|
||||
};
|
||||
|
||||
130
frontend/src/hooks/query/use-git-repositories.ts
Normal file
130
frontend/src/hooks/query/use-git-repositories.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
import { useAppInstallations } from "./use-app-installations";
|
||||
import { GitRepository } from "../../types/git";
|
||||
import { Provider } from "../../types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { shouldUseInstallationRepos } from "#/utils/utils";
|
||||
|
||||
interface UseGitRepositoriesOptions {
|
||||
provider: Provider | null;
|
||||
pageSize?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface UserRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
}
|
||||
|
||||
interface InstallationRepositoriesResponse {
|
||||
data: GitRepository[];
|
||||
nextPage: number | null;
|
||||
installationIndex: number | null;
|
||||
}
|
||||
|
||||
export function useGitRepositories(options: UseGitRepositoriesOptions) {
|
||||
const { provider, pageSize = 30, enabled = true } = options;
|
||||
const { providers } = useUserProviders();
|
||||
const { data: config } = useConfig();
|
||||
const { data: installations } = useAppInstallations(provider);
|
||||
|
||||
const useInstallationRepos = provider
|
||||
? shouldUseInstallationRepos(provider, config?.APP_MODE)
|
||||
: false;
|
||||
|
||||
const repos = useInfiniteQuery<
|
||||
UserRepositoriesResponse | InstallationRepositoriesResponse
|
||||
>({
|
||||
queryKey: [
|
||||
"repositories",
|
||||
providers || [],
|
||||
provider,
|
||||
useInstallationRepos,
|
||||
pageSize,
|
||||
...(useInstallationRepos ? [installations || []] : []),
|
||||
],
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!provider) {
|
||||
throw new Error("Provider is required");
|
||||
}
|
||||
|
||||
if (useInstallationRepos) {
|
||||
const { repoPage, installationIndex } = pageParam as {
|
||||
installationIndex: number | null;
|
||||
repoPage: number | null;
|
||||
};
|
||||
|
||||
if (!installations) {
|
||||
throw new Error("Missing installation list");
|
||||
}
|
||||
|
||||
return OpenHands.retrieveInstallationRepositories(
|
||||
provider,
|
||||
installationIndex || 0,
|
||||
installations,
|
||||
repoPage || 1,
|
||||
pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
return OpenHands.retrieveUserGitRepositories(
|
||||
provider,
|
||||
pageParam as number,
|
||||
pageSize,
|
||||
);
|
||||
},
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (useInstallationRepos) {
|
||||
const installationPage = lastPage as InstallationRepositoriesResponse;
|
||||
if (installationPage.nextPage) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: installationPage.nextPage,
|
||||
};
|
||||
}
|
||||
|
||||
if (installationPage.installationIndex !== null) {
|
||||
return {
|
||||
installationIndex: installationPage.installationIndex,
|
||||
repoPage: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const userPage = lastPage as UserRepositoriesResponse;
|
||||
return userPage.nextPage;
|
||||
},
|
||||
initialPageParam: useInstallationRepos
|
||||
? { installationIndex: 0, repoPage: 1 }
|
||||
: 1,
|
||||
enabled:
|
||||
enabled &&
|
||||
(providers || []).length > 0 &&
|
||||
!!provider &&
|
||||
(!useInstallationRepos ||
|
||||
(Array.isArray(installations) && installations.length > 0)),
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const onLoadMore = () => {
|
||||
if (repos.hasNextPage && !repos.isFetchingNextPage) {
|
||||
repos.fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
data: repos.data,
|
||||
isLoading: repos.isLoading,
|
||||
isError: repos.isError,
|
||||
hasNextPage: repos.hasNextPage,
|
||||
isFetchingNextPage: repos.isFetchingNextPage,
|
||||
fetchNextPage: repos.fetchNextPage,
|
||||
onLoadMore,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user