Compare commits

..

2 Commits

Author SHA1 Message Date
openhands
a6e91d1021 Add support for organization-level setup.sh with minimal changes 2025-07-10 15:20:05 +00:00
openhands
c7e9f99759 Add support for organization-level setup.sh 2025-07-10 15:11:56 +00:00
268 changed files with 2728 additions and 19222 deletions

View File

@@ -40,8 +40,7 @@ jobs:
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
@@ -59,6 +58,9 @@ jobs:
permissions:
contents: read
packages: write
outputs:
# Since this job uses outputs it cannot use matrix
hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -84,11 +86,24 @@ jobs:
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
- name: Build app image
if: "github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --load
- name: Get hash in App Image
id: get_hash_in_app_image
run: |
# Run the build script in the app image
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
# Get the hash from the build script
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
echo "Hash from app image: $hash_from_app_image"
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
runs-on: blacksmith-8vcpu-ubuntu-2204
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
@@ -115,13 +130,22 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# This is the one that saves the cache, the others set 'lookup-only: true'
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
@@ -166,6 +190,61 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
verify_hash_equivalence_in_runtime_and_app:
name: Verify Hash Equivalence in Runtime and Docker images
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime, ghcr_build_app]
strategy:
fail-fast: false
matrix:
base_image: ['nikolaik']
env:
BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Get hash in App Image
run: |
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV
- name: Get hash using code (development mode)
run: |
mkdir -p containers/runtime
poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV
- name: Compare hashes
run: |
echo "Hash from App Image: ${{ env.hash_from_app_image }}"
echo "Hash from Code: ${{ env.hash_from_code }}"
if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
echo "Hashes match!"
else
echo "Hashes do not match!"
exit 1
fi
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
@@ -197,17 +276,25 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -259,17 +346,25 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -296,7 +391,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -305,7 +400,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
steps:
- name: Some tests failed
run: |
@@ -330,7 +425,6 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
shell: bash
run: |
echo "updating PR description"
DOCKER_RUN_COMMAND="docker run -it --rm \

View File

@@ -54,7 +54,7 @@ jobs:
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
run: poetry install --with dev,test,runtime
- name: Configure config.toml for testing with Haiku
env:
@@ -179,8 +179,8 @@ jobs:
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
# if triggered by PR, use PR number, otherwise use 5318 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5318 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}

View File

@@ -1,108 +0,0 @@
name: Publish OpenHands UI Package
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
paths:
- "openhands-ui/**"
- ".github/workflows/npm-publish-ui.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: npm-publish-ui
cancel-in-progress: false
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
outputs:
should-publish: ${{ steps.version-check.outputs.should-publish }}
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
- name: Check if version changed
id: version-check
run: |
# Get current version from package.json
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if package.json version changed in this commit
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
# Check if the version field specifically changed
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
echo "Version changed in package.json, will publish"
echo "should-publish=true" >> $GITHUB_OUTPUT
else
echo "package.json changed but version did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
else
echo "package.json did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build
- name: Check if package already exists on npm
id: npm-check
working-directory: ./openhands-ui
run: |
PACKAGE_NAME=$(jq -r .name package.json)
VERSION="${{ needs.check-version.outputs.current-version }}"
# Check if this version already exists on npm
if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
echo "Version $VERSION already exists on npm, skipping publish"
echo "already-exists=true" >> $GITHUB_OUTPUT
else
echo "Version $VERSION does not exist on npm, proceeding with publish"
echo "already-exists=false" >> $GITHUB_OUTPUT
fi
- name: Setup npm authentication
if: steps.npm-check.outputs.already-exists == 'false'
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Publish to npm
if: steps.npm-check.outputs.already-exists == 'false'
working-directory: ./openhands-ui
run: |
# The prepublishOnly script will run automatically and build the package
npm publish
echo "✅ Successfully published @openhands/ui@${{ needs.check-version.outputs.current-version }} to npm"

View File

@@ -1,5 +1,5 @@
# Workflow that runs python tests
name: Run Python Tests
# Workflow that runs python unit tests
name: Run Python Unit Tests
# The jobs in this workflow are required, so they must run at all times
# * Always run on "main"
@@ -16,9 +16,9 @@ concurrency:
cancel-in-progress: true
jobs:
# Run python tests on Linux
# Run python unit tests on Linux
test-on-linux:
name: Python Tests on Linux
name: Python Unit Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
@@ -51,8 +51,6 @@ jobs:
run: poetry run pytest --forked -n auto -svv ./tests/unit
- name: Run Runtime Tests with CLIRuntime
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
- name: Run E2E Tests
run: poetry run pytest -svv tests/e2e
# Run specific Windows python tests
test-on-windows:

View File

@@ -1,135 +1,56 @@
# Run evaluation on a PR, after releases, or manually
# Run evaluation on a PR
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
# Runs when a PR is labeled with one of the "run-eval-" labels
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout branch
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
ref: ${{ github.head_ref }}
- name: Set evaluation parameters
id: eval_params
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on issue/PR
- name: Comment on PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
Running evaluation on the PR. Once eval is done, the results will be posted.

View File

@@ -1,34 +0,0 @@
name: Run UI Component Build
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
pull_request:
paths:
- 'openhands-ui/**'
- '.github/workflows/ui-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build

View File

@@ -137,65 +137,3 @@ Your specialized knowledge and instructions here...
2. Add the setting to the backend:
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
- Update any relevant backend code to apply the setting (e.g., in session creation)
### Adding New LLM Models
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
#### Model Configuration Procedure:
1. **Frontend Model Arrays** (`frontend/src/utils/verified-models.ts`):
- Add the model to `VERIFIED_MODELS` array (main list of all verified models)
- Add to provider-specific arrays based on the model's provider:
- `VERIFIED_OPENAI_MODELS` for OpenAI models
- `VERIFIED_ANTHROPIC_MODELS` for Anthropic models
- `VERIFIED_MISTRAL_MODELS` for Mistral models
- `VERIFIED_OPENHANDS_MODELS` for models available through OpenHands provider
2. **Backend CLI Integration** (`openhands/cli/utils.py`):
- Add the model to the appropriate `VERIFIED_*_MODELS` arrays
- This ensures the model appears in CLI model selection
3. **Backend Model List** (`openhands/utils/llm.py`):
- **CRITICAL**: Add the model to the `openhands_models` list (lines 57-66) if using OpenHands provider
- This is required for the model to appear in the frontend model selector
- Format: `'openhands/model-name'` (e.g., `'openhands/o3'`)
4. **Backend LLM Configuration** (`openhands/llm/llm.py`):
- Add to feature-specific arrays based on model capabilities:
- `FUNCTION_CALLING_SUPPORTED_MODELS` if the model supports function calling
- `REASONING_EFFORT_SUPPORTED_MODELS` if the model supports reasoning effort parameters
- `CACHE_PROMPT_SUPPORTED_MODELS` if the model supports prompt caching
- `MODELS_WITHOUT_STOP_WORDS` if the model doesn't support stop words
5. **Validation**:
- Run backend linting: `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml`
- Run frontend linting: `cd frontend && npm run lint:fix`
- Run frontend build: `cd frontend && npm run build`
#### Model Verification Arrays:
- **VERIFIED_MODELS**: Main array of all verified models shown in the UI
- **VERIFIED_OPENAI_MODELS**: OpenAI models (LiteLLM doesn't return provider prefix)
- **VERIFIED_ANTHROPIC_MODELS**: Anthropic models (LiteLLM doesn't return provider prefix)
- **VERIFIED_MISTRAL_MODELS**: Mistral models (LiteLLM doesn't return provider prefix)
- **VERIFIED_OPENHANDS_MODELS**: Models available through OpenHands managed provider
#### Model Feature Support Arrays:
- **FUNCTION_CALLING_SUPPORTED_MODELS**: Models that support structured function calling
- **REASONING_EFFORT_SUPPORTED_MODELS**: Models that support reasoning effort parameters (like o1, o3)
- **CACHE_PROMPT_SUPPORTED_MODELS**: Models that support prompt caching for efficiency
- **MODELS_WITHOUT_STOP_WORDS**: Models that don't support stop word parameters
#### Frontend Model Integration:
- Models are automatically available in the model selector UI once added to verified arrays
- The `extractModelAndProvider` utility automatically detects provider from model arrays
- Provider-specific models are grouped and prioritized in the UI selection
#### CLI Model Integration:
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider

View File

@@ -1,32 +1,29 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
echo "Frontend changes detected. Running frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
@@ -53,7 +50,7 @@ if [ -n "$frontend_changes" ]; then
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user

View File

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

View File

@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **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.

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。

View File

@@ -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.49-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

View File

@@ -45,7 +45,6 @@ ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=/.openhands
ENV INIT_GIT_IN_EMPTY_WORKSPACE=1
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE

View File

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

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-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:

View File

@@ -34,7 +34,6 @@
{
"group": "Integrations",
"pages": [
"usage/cloud/bitbucket-installation",
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation",
"usage/cloud/slack-installation"
@@ -67,9 +66,7 @@
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/moonshot",
"usage/llms/openai-llms",
"usage/llms/openhands-llms",
"usage/llms/openrouter"
]
}

View File

@@ -1827,11 +1827,6 @@
"updated_at": {
"type": "string",
"format": "date-time"
},
"owner_type": {
"type": "string",
"enum": ["user", "organization"],
"nullable": true
}
}
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,25 +0,0 @@
---
title: Bitbucket Integration
description: This guide walks you through the process of installing OpenHands Cloud for your Bitbucket repositories. Once
set up, it will allow OpenHands to work with your Bitbucket repository.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.

View File

@@ -9,9 +9,8 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.

View File

@@ -51,7 +51,8 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.
@@ -64,8 +65,6 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -1,7 +1,7 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
set up, it will allow OpenHands to work with your GitLab repository.
---
## Prerequisites
@@ -17,7 +17,7 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
![Connect Repo](/static/img/connect-repo.png)
## Using Tokens with Reduced Scopes
@@ -25,33 +25,6 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
<Note>
This feature works for personal projects and is available for group projects with a
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a merge request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Merge Requests
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -8,9 +8,9 @@ description: Getting started with OpenHands Cloud.
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub, GitLab or Bitbucket account:
You'll be prompted to connect with your GitHub or GitLab account:
1. Click `Log in with GitHub`, `Log in with GitLab` or `Log in with Bitbucket`.
1. Click `Log in with GitHub` or `Log in with GitLab`.
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
@@ -22,6 +22,5 @@ Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Install Bitbucket Integration](/usage/cloud/bitbucket-installation) to use OpenHands with your Bitbucket repositories.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.

View File

@@ -12,8 +12,7 @@ icon: question
[GitHub](/usage/cloud/github-installation), [GitLab](/usage/cloud/gitlab-installation),
and [Slack](/usage/cloud/slack-installation) integrations.
2. **Run on your own**: If you prefer to run it on your own hardware, follow our [Getting Started guide](/usage/local-setup).
3. **First steps**: Read over the [start building guidelines](/usage/getting-started) and
[prompting best practices](/usage/prompting/prompting-best-practices) to learn the basics.
3. **First steps**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
### Can I use OpenHands for production workloads?

View File

@@ -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.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49 \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
python -m openhands.cli.main --override-cli-mode true
```
@@ -123,8 +123,7 @@ docker run -it \
This launches the CLI in Docker, allowing you to interact with OpenHands.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
The conversation history will be saved in `~/.openhands/sessions`.

View File

@@ -25,8 +25,7 @@ You can use the Settings page at any time to:
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
- Set application settings like your preferred language, notifications and other preferences.
- [Manage custom secrets](/usage/common-settings#secrets-management).

View File

@@ -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.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,14 +73,13 @@ 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.49 \
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Additional Options
@@ -91,6 +90,6 @@ Common command-line options:
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
Run `poetry run python -m openhands.core.main --help` for all options.
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
Set `export LOG_ALL_EVENTS=true` to log all agent actions.

View File

@@ -10,8 +10,7 @@ This section is for users who want to connect OpenHands to different LLMs.
## Model Recommendations
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Our latest benchmarking results can be found in
[this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
@@ -21,7 +20,6 @@ Based on these findings and community feedback, these are the latest models that
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
to help others using the same provider!
@@ -72,20 +70,17 @@ We have a few guides for running OpenHands with specific model providers:
- [Groq](/usage/llms/groq)
- [Local LLMs with SGLang or vLLM](/usage/llms/local-llms)
- [LiteLLM Proxy](/usage/llms/litellm-proxy)
- [Moonshot AI](/usage/llms/moonshot)
- [OpenAI](/usage/llms/openai-llms)
- [OpenHands](/usage/llms/openhands-llms)
- [OpenRouter](/usage/llms/openrouter)
## Model Customization
LLM providers have specific settings that can be customized to optimize their performance with OpenHands, such as:
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer.
- **Native Tool Calling**: Toggle native function/tool calling capabilities.
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer
- **Native Tool Calling**: Toggle native function/tool calling capabilities
For detailed information about model customization, see
[LLM Configuration Options](/usage/configuration-options#llm-configuration).
For detailed information about model customization, see [LLM Configuration Options](configuration-options#llm-customization).
### API retries and rate limits

View File

@@ -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.49-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
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.49
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.48
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None

View File

@@ -1,25 +0,0 @@
---
title: Moonshot AI
description: How to use Moonshot AI models with OpenHands
---
## Using Moonshot AI with OpenHands
[Moonshot AI](https://platform.moonshot.ai/) offers several powerful models, including Kimi-K2, which has been verified to work well with OpenHands.
### Setup
1. Sign up for an account at [Moonshot AI Platform](https://platform.moonshot.ai/)
2. Generate an API key from your account settings
3. Configure OpenHands to use Moonshot AI:
| Setting | Value |
| --- | --- |
| LLM Provider | `moonshot` |
| LLM Model | `kimi-k2-0711-preview` |
| API Key | Your Moonshot API key |
### Recommended Models
- `moonshot/kimi-k2-0711-preview` - Kimi-K2 is Moonshot's most powerful model with a 131K context window, function calling support, and web search capabilities.

View File

@@ -1,34 +0,0 @@
---
title: OpenHands
description: OpenHands LLM provider with access to state-of-the-art (SOTA) agentic coding models.
---
## Obtain Your OpenHands LLM API Key
1. [Log in to OpenHands Cloud](/usage/cloud/openhands-cloud).
2. Go to the Settings page and navigate to the `API Keys` tab.
3. Copy your `LLM API Key`.
![OpenHands LLM API Key](/static/img/openhands-llm-api-key.png)
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `OpenHands`
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514)
- `API Key` to your OpenHands LLM API key copied from above
## Using OpenHands LLM Provider in the CLI
1. [Run OpenHands CLI](/usage/how-to/cli-mode).
2. To select OpenHands as the LLM provider:
- If this is your first time running the CLI, choose `openhands` and then select the model that you would like to use.
- If you have previously run the CLI, run the `/settings` command and select to modify the `Basic` settings. Then
choose `openhands` and finally the model.
![OpenHands Provider in CLI](/static/img/openhands-provider-cli.png)
## 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)

View File

@@ -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.49-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.48
```
> **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.

View File

@@ -29,15 +29,6 @@ sse_servers = [
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# Stdio Servers - Local processes that communicate via standard input/output
stdio_servers = [
# Basic stdio server
@@ -66,22 +57,6 @@ SSE servers are configured using either a string URL or an object with the follo
- Type: `str`
- Description: The URL of the SSE server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### Stdio Servers
Stdio servers are configured using an object with the following properties:
@@ -109,7 +84,7 @@ Stdio servers are configured using an object with the following properties:
When OpenHands starts, it:
1. Reads the MCP configuration.
2. Connects to any configured SSE and SHTTP servers.
2. Connects to any configured SSE servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
@@ -118,23 +93,3 @@ The agent can then use these tools just like any built-in tool. When the agent c
1. OpenHands routes the call to the appropriate MCP server.
2. The server processes the request and returns a response.
3. OpenHands converts the response to an observation and presents it to the agent.
## Transport Protocols
OpenHands supports three different MCP transport protocols:
### Server-Sent Events (SSE)
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
### Streamable HTTP (SHTTP)
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
### Standard Input/Output (stdio)
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.

View File

@@ -101,14 +101,13 @@ The OpenHands evaluation harness supports a wide variety of benchmarks across [s
- SWE-Bench: [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench)
- HumanEvalFix: [`evaluation/benchmarks/humanevalfix`](./benchmarks/humanevalfix)
- BIRD: [`evaluation/benchmarks/bird`](./benchmarks/bird)
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
- BioCoder: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- ML-Bench: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- APIBench: [`evaluation/benchmarks/gorilla`](./benchmarks/gorilla/)
- ToolQA: [`evaluation/benchmarks/toolqa`](./benchmarks/toolqa/)
- AiderBench: [`evaluation/benchmarks/aider_bench`](./benchmarks/aider_bench/)
- Commit0: [`evaluation/benchmarks/commit0_bench`](./benchmarks/commit0_bench/)
- DiscoveryBench: [`evaluation/benchmarks/discoverybench`](./benchmarks/discoverybench/)
- TerminalBench: [`evaluation/benchmarks/terminal_bench`](./benchmarks/terminal_bench)
### Web Browsing

View File

@@ -117,7 +117,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -41,10 +41,6 @@ default, it is set to 1.
- `language`, the language of your evaluating dataset.
- `dataset`, the absolute position of the dataset jsonl.
**Skipping errors on build**
For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
The results will be generated in evaluation/evaluation_outputs/outputs/XXX/CodeActAgent/YYY/output.jsonl, you can refer to the [example](examples/output.jsonl).
## Runing evaluation

View File

@@ -17,7 +17,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -345,7 +344,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -845,5 +843,3 @@ if __name__ == '__main__':
timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
max_retries=5,
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)

View File

@@ -38,10 +38,6 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
> - If your LLM config has temperature=0, we will automatically use temperature=0.1 for the 2nd and 3rd attempts
>
> To enable this iterative protocol, set `export ITERATIVE_EVAL_MODE=true`
>
> **Skipping errors on build**
>
> For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
### Running Locally with Docker

View File

@@ -1,45 +0,0 @@
# **Localization Evaluation for SWE-Bench**
This folder implements localization evaluation at both file and function levels to complementing the assessment of agent inference on [SWE-Bench](https://www.swebench.com/).
## **1. Environment Setup**
- Python env: [Install python environment](../../../README.md#development-environment)
- LLM config: [Configure LLM config](../../../README.md#configure-openhands-and-your-llm)
## **2. Inference & Evaluation**
- Inference and evaluation follow the original `run_infer.sh` and `run_eval.sh` implementation
- You may refer to instructions at [README.md](../README.md) for running inference and evaluation on SWE-Bench
## **3. Localization Evaluation**
- Localization evaluation computes two-level localization accuracy, while also considers task success as an additional metric for overall evaluation:
- **File Localization Accuracy:** Accuracy of correctly localizing the target file
- **Function Localization Accuracy:** Accuracy of correctly localizing the target function
- **Resolve Rate** (will be auto-skipped if missing): Success rate of whether tasks are successfully resolved
- **File Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Function Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Task success efficiency:** Average number of iterations taken to resolve the task
- **Resource efficiency:** the API expenditure of the agent running inference on SWE-Bench instances
- Run localization evaluation
- Format:
```bash
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh [infer-dir] [split] [dataset] [max-infer-turn] [align-with-max]
```
- `infer-dir`: inference directory containing inference outputs
- `split`: SWE-Bench dataset split to use
- `dataset`: SWE-Bench dataset name
- `max-infer-turn`: the maximum number of iterations the agent took to run inference
- `align-with-max`: whether to align failure indices (e.g., incorrect localization, unresolved tasks) with `max_iter`
- Example:
```bash
# Example
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh \
--infer-dir ./evaluation/evaluation_outputs/outputs/princeton-nlp__SWE-bench_Verified-test/CodeActAgent/gpt_4o_100_N \
--split test \
--dataset princeton-nlp/SWE-bench_Verified \
--max-infer-turn 100 \
--align-with-max true
```
- Localization evaluation results will be automatically saved to `[infer-dir]/loc_eval`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -226,7 +225,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -970,5 +968,3 @@ if __name__ == '__main__':
logger.info(
f'Done! Total {len(added_instance_ids)} instances added to {output_file}'
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)

View File

@@ -203,7 +203,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -1,227 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Function to display usage information
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --infer-dir DIR Directory containing model inference outputs"
echo " --split SPLIT SWE-Bench dataset split selection"
echo " --dataset DATASET Dataset name"
echo " --max-infer-turn NUM Max number of turns for coding agent"
echo " --align-with-max BOOL Align failed instance indices with max iteration (true/false)"
echo " -h, --help Display this help message"
echo ""
echo "Example:"
echo " $0 --infer-dir ./inference_outputs --split test --align-with-max false"
}
# Check if no arguments were provided
if [ $# -eq 0 ]; then
usage
exit 1
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--infer-dir)
INFER_DIR="$2"
shift 2
;;
--split)
SPLIT="$2"
shift 2
;;
--dataset)
DATASET="$2"
shift 2
;;
--max-infer-turn)
MAX_TURN="$2"
shift 2
;;
--align-with-max)
ALIGN_WITH_MAX="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
# Check for required arguments (only INFER_DIR is required)
if [ -z "$INFER_DIR" ]; then
echo "Error: Missing required arguments (--infer-dir is required)"
usage
exit 1
fi
# Set defaults for optional arguments if not provided
if [ -z "$SPLIT" ]; then
SPLIT="test"
echo "Split not specified, using default: $SPLIT"
fi
if [ -z "$DATASET" ]; then
DATASET="princeton-nlp/SWE-bench_Verified"
echo "Dataset not specified, using default: $DATASET"
fi
if [ -z "$MAX_TURN" ]; then
MAX_TURN=20
echo "Max inference turn not specified, using default: $MAX_TURN"
fi
if [ -z "$ALIGN_WITH_MAX" ]; then
ALIGN_WITH_MAX="true"
echo "Align with max not specified, using default: $ALIGN_WITH_MAX"
fi
# Validate align-with-max value
if [ "$ALIGN_WITH_MAX" != "true" ] && [ "$ALIGN_WITH_MAX" != "false" ]; then
print_error "Invalid value for --align-with-max: $ALIGN_WITH_MAX. Must be 'true' or 'false'"
exit 1
fi
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_header() {
echo -e "${BLUE}[TASK]${NC} $1"
}
# Check if Python is available
print_header "Checking Python installation..."
if ! command -v python3 &> /dev/null; then
if ! command -v python &> /dev/null; then
print_error "Python is not installed or not in PATH"
exit 1
else
PYTHON_CMD="python"
print_status "Using python command"
fi
else
PYTHON_CMD="python3"
print_status "Using python3 command"
fi
# Check if the Python script exists
SCRIPT_NAME="./evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py"
if [ ! -f "$SCRIPT_NAME" ]; then
print_error "Python script '$SCRIPT_NAME' not found in current directory"
print_warning "Make sure the Python script is in the same directory as this bash script"
exit 1
fi
# Check if required directories exist
print_header "Validating directories..."
if [ ! -d "$INFER_DIR" ]; then
print_error "Inference directory not found: $INFER_DIR"
exit 1
fi
# Evaluation outputs
EVAL_DIR="$INFER_DIR/eval_outputs"
# Display configuration
print_header "Starting Localization Evaluation with the following configuration:"
echo " Inference Directory: $INFER_DIR"
if [ -d "$EVAL_DIR" ]; then
echo " Evaluation Directory: $EVAL_DIR"
else
echo " Evaluation Directory: None (evaluation outputs doesn't exist)"
fi
echo " Output Directory: $INFER_DIR/loc_eval"
echo " Split: $SPLIT"
echo " Dataset: $DATASET"
echo " Max Turns: $MAX_TURN"
echo " Align with Max: $ALIGN_WITH_MAX"
echo " Python Command: $PYTHON_CMD"
echo ""
# Check Python dependencies (optional check)
print_header "Checking Python dependencies..."
$PYTHON_CMD -c "
import sys
required_modules = ['pandas', 'json', 'os', 'argparse', 'collections']
missing_modules = []
for module in required_modules:
try:
__import__(module)
except ImportError:
missing_modules.append(module)
if missing_modules:
print(f'Missing required modules: {missing_modules}')
sys.exit(1)
else:
print('All basic dependencies are available')
" || {
print_error "Some Python dependencies are missing"
print_warning "Please install required packages: pip install pandas"
exit 1
}
# Create log directory if doesn't exists
mkdir -p "$INFER_DIR/loc_eval"
# Set up logging
LOG_FILE="$INFER_DIR/loc_eval/loc_evaluation_$(date +%Y%m%d_%H%M%S).log"
print_status "Logging output to: $LOG_FILE"
# Build the command
CMD_ARGS="\"$SCRIPT_NAME\" \
--infer-dir \"$INFER_DIR\" \
--split \"$SPLIT\" \
--dataset \"$DATASET\" \
--max-infer-turn \"$MAX_TURN\" \
--align-with-max \"$ALIGN_WITH_MAX\""
# Run the Python script
print_header "Running localization evaluation..."
eval "$PYTHON_CMD $CMD_ARGS" 2>&1 | tee "$LOG_FILE"
# Check if the script ran successfully
if [ ${PIPESTATUS[0]} -eq 0 ]; then
print_status "Localization evaluation completed successfully!"
print_status "Results saved to: $INFER_DIR/loc_eval"
print_status "Log file: $LOG_FILE"
# Display summary if results exist
if [ -f "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json" ]; then
print_header "Evaluation Summary:"
cat "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json"
echo
fi
else
print_error "Localization evaluation failed!"
print_warning "Check the log file for details: $LOG_FILE"
exit 1
fi

View File

@@ -1,31 +0,0 @@
# Terminal-Bench Evaluation on OpenHands
Terminal-Bench has its own evaluation harness that is very different from OpenHands'. We
implemented [OpenHands agent](https://github.com/laude-institute/terminal-bench/tree/main/terminal_bench/agents/installed_agents/openhands) using OpenHands local runtime
inside terminal-bench framework. Hereby we introduce how to use the terminal-bench
harness to evaluate OpenHands.
## Installation
Terminal-bench ships a CLI tool to manage tasks and run evaluation.
Please follow official [Installation Doc](https://www.tbench.ai/docs/installation). You could also clone terminal-bench [source code](https://github.com/laude-institute/terminal-bench) and use `uv run tb` CLI.
## Evaluation
Please see [Terminal-Bench Leaderboard](https://www.tbench.ai/leaderboard) for the latest
instruction on benchmarking guidance. The dataset might evolve.
Sample command:
```bash
export LLM_BASE_URL=<optional base url>
export LLM_API_KEY=<llm key>
tb run \
--dataset-name terminal-bench-core \
--dataset-version 0.1.1 \
--agent openhands \
--model <model> \
--cleanup
```
You could run `tb --help` or `tb run --help` to learn more about their CLI.

View File

@@ -164,7 +164,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace

View File

@@ -25,8 +25,7 @@ class Test(BaseIntegrationTest):
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git add
cmd_str = 'git add hello.py'
action = CmdRunAction(command=cmd_str)
action = CmdRunAction(command='git add hello.py .vscode/')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@@ -41,6 +40,15 @@ class Test(BaseIntegrationTest):
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
)
# check if the file /workspace/.vscode/settings.json exists
action = CmdRunAction(command='cat /workspace/.vscode/settings.json')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/.vscode/settings.json: {obs.content}.',
)
# check if the staging area is empty
action = CmdRunAction(command='git status')
obs = runtime.run_action(action)

View File

@@ -311,76 +311,6 @@ def assert_and_raise(condition: bool, msg: str):
raise EvalException(msg)
def log_skipped_maximum_retries_exceeded(instance, metadata, error, max_retries=5):
"""Log and skip the instance when maximum retries are exceeded.
Args:
instance: The instance that failed
metadata: The evaluation metadata
error: The error that occurred
max_retries: The maximum number of retries that were attempted
Returns:
EvalOutput with the error information
"""
from openhands.core.logger import openhands_logger as logger
# Log the error
logger.exception(error)
logger.error(
f'Maximum error retries reached for instance {instance.instance_id}. '
f'Check maximum_retries_exceeded.jsonl, fix the issue and run evaluation again. '
f'Skipping this instance and continuing with others.'
)
# Add the instance name to maximum_retries_exceeded.jsonl in the same folder as output.jsonl
if metadata and metadata.eval_output_dir:
retries_file_path = os.path.join(
metadata.eval_output_dir,
'maximum_retries_exceeded.jsonl',
)
try:
# Write the instance info as a JSON line
with open(retries_file_path, 'a') as f:
import json
# No need to get Docker image as we're not including it in the error entry
error_entry = {
'instance_id': instance.instance_id,
'error': str(error),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
}
f.write(json.dumps(error_entry) + '\n')
logger.info(f'Added instance {instance.instance_id} to {retries_file_path}')
except Exception as write_error:
logger.error(
f'Failed to write to maximum_retries_exceeded.jsonl: {write_error}'
)
return EvalOutput(
instance_id=instance.instance_id,
test_result={},
error=f'Maximum retries ({max_retries}) reached: {str(error)}',
status='error',
)
def check_maximum_retries_exceeded(eval_output_dir):
"""Check if maximum_retries_exceeded.jsonl exists and output a message."""
from openhands.core.logger import openhands_logger as logger
retries_file_path = os.path.join(eval_output_dir, 'maximum_retries_exceeded.jsonl')
if os.path.exists(retries_file_path):
logger.info(
'ATTENTION: Some instances reached maximum error retries and were skipped.'
)
logger.info(f'These instances are listed in: {retries_file_path}')
logger.info(
'Fix these instances and run evaluation again with EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false'
)
def _process_instance_wrapper(
process_instance_func: Callable[[pd.Series, EvalMetadata, bool], EvalOutput],
instance: pd.Series,
@@ -433,26 +363,11 @@ def _process_instance_wrapper(
+ f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]'
+ '-' * 10
)
# Check if EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED is set to true
skip_errors = (
os.environ.get(
'EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED', 'false'
).lower()
== 'true'
)
if skip_errors:
# Use the dedicated function to log and skip maximum retries exceeded
return log_skipped_maximum_retries_exceeded(
instance, metadata, e, max_retries
)
else:
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
msg = (
'-' * 10
+ '\n'
@@ -541,10 +456,6 @@ def run_evaluation(
output_fp.close()
logger.info('\nEvaluation finished.\n')
# Check if any instances reached maximum retries
if metadata and metadata.eval_output_dir:
check_maximum_retries_exceeded(metadata.eval_output_dir)
def reset_logger_for_multiprocessing(
logger: logging.Logger, instance_id: str, log_dir: str

View File

@@ -129,159 +129,4 @@ describe("ActionSuggestions", () => {
expect(createPRPrompt).toContain("meaningful branch name");
expect(createPRPrompt).not.toContain("SAME branch name");
});
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
// Test case for GitHub repository
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-github",
title: "GitHub Test",
selected_repository: "test-repo",
git_provider: "github",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
// Mock user having both GitHub and Bitbucket tokens
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "github-token",
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(prButton).toBeInTheDocument();
if (prButton) {
prButton.click();
}
// The suggestion should mention GitHub, not Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitHub")
);
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
it("should use GitLab terminology when git_provider is gitlab", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-gitlab",
title: "GitLab Test",
selected_repository: "test-repo",
git_provider: "gitlab",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "gitlab-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention GitLab and "merge request" instead of "pull request"
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitLab")
);
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("merge request")
);
});
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-bitbucket",
title: "Bitbucket Test",
selected_repository: "test-repo",
git_provider: "bitbucket",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
});

View File

@@ -19,13 +19,7 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>,
);
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -41,13 +35,7 @@ describe("AuthModal", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -56,63 +44,4 @@ describe("AuthModal", () => {
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
);
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
expect(termsSection).toHaveTextContent("COMMON$AND");
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
// Check Terms of Service link
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toBeInTheDocument();
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveClass("underline", "hover:text-primary");
// Check Privacy Policy link
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute(
"href",
"https://www.all-hands.dev/privacy",
);
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
// Verify that both links are within the terms section
expect(termsSection).toContainElement(tosLink);
expect(termsSection).toContainElement(privacyLink);
});
it("should open Terms of Service link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toHaveAttribute("target", "_blank");
});
it("should open Privacy Policy link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -16,6 +16,8 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
@@ -122,8 +124,7 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -131,8 +132,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -141,32 +140,15 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
// Simulate context menu being opened by parent
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -175,18 +157,18 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
@@ -216,11 +198,7 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
let menuOpen = true;
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
menuOpen = isOpen;
});
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -228,27 +206,10 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
await clickOnEditButton(user);
// Re-render with updated state
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
@@ -266,7 +227,6 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -275,8 +235,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -313,7 +271,6 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -322,8 +279,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -337,7 +292,6 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -346,11 +300,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
@@ -360,7 +315,7 @@ describe("ConversationCard", () => {
});
it("should show display cost button only when showOptions is true", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -369,17 +324,21 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
@@ -389,11 +348,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
@@ -401,7 +361,6 @@ describe("ConversationCard", () => {
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -411,11 +370,12 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
@@ -426,7 +386,7 @@ describe("ConversationCard", () => {
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
@@ -434,15 +394,19 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
@@ -450,11 +414,10 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),

View File

@@ -529,287 +529,4 @@ describe("ConversationPanel", () => {
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
it("should show edit button in context menu", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Should find input field instead of title text
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
expect(titleInput).toBeInTheDocument();
expect(titleInput.tagName).toBe("INPUT");
expect(titleInput).toHaveValue("Conversation 1");
expect(titleInput).toHaveFocus();
});
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
// Mock the toast function
const mockToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: mockToast,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
// Blur the input to save
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title and press Enter
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Title Updated via Enter");
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with extra whitespace
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, " Trimmed Title ");
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Clear the title completely
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.tab();
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockRejectedValue(new Error("API Error"));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Failed Update");
await user.tab();
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
// Wait for error handling
await waitFor(() => {
expect(updateConversationSpy).toHaveBeenCalled();
});
});
it("should close context menu when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Verify context menu is open
const contextMenu = screen.getByTestId("context-menu");
expect(contextMenu).toBeInTheDocument();
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Verify context menu is closed
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with special characters
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Special @#$%^&*()_+ Characters");
await user.tab();
// Verify API call was made with special characters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
});
});

View File

@@ -110,29 +110,4 @@ describe("TaskCard", () => {
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
it("should navigate to the conversation page after creating a conversation", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "test-conversation-id",
title: "Test Conversation",
selected_repository: "repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null
});
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
// Wait for navigation to the conversation page
await screen.findByTestId("conversation-screen");
});
});

View File

@@ -82,5 +82,17 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",
separator: "/",
});
expect(extractModelAndProvider("claude-2.1")).toEqual({
provider: "anthropic",
model: "claude-2.1",
separator: "/",
});
});
});

View File

@@ -52,16 +52,14 @@ test("organizeModelsAndProviders", () => {
separator: "/",
models: [
"claude-3-5-sonnet-20241022",
],
},
other: {
separator: "",
models: [
"together-ai-21.1b-41b",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",
],
},
other: {
separator: "",
models: ["together-ai-21.1b-41b"],
},
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,39 @@
{
"name": "openhands-frontend",
"version": "0.49.0",
"version": "0.48.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.1",
"@heroui/react": "^2.8.0-beta.13",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.0",
"@react-router/serve": "^7.7.0",
"@react-router/node": "^7.6.3",
"@react-router/serve": "^7.6.3",
"@react-types/shared": "^3.29.1",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.5.0",
"@stripe/stripe-js": "^7.4.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@vitejs/plugin-react": "^4.7.0",
"@tanstack/react-query": "^5.81.4",
"@vitejs/plugin-react": "^4.6.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.6",
"framer-motion": "^12.23.0",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.12",
"jose": "^6.0.11",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -42,15 +42,14 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.7.0",
"react-router": "^7.6.3",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.5",
"vite": "^7.0.3",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -82,17 +81,17 @@
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.1",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.1",
"@react-router/dev": "^7.7.0",
"@playwright/test": "^1.53.2",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.12",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,11 +105,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.3'
const PACKAGE_VERSION = '2.10.2'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -13,13 +13,11 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} 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 { RepositoryMicroagent } from "#/types/microagent-management";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -252,28 +250,6 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -285,7 +261,6 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -294,7 +269,6 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(
@@ -490,22 +464,6 @@ class OpenHands {
return data;
}
/**
* Get the available microagents for a specific repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -519,18 +477,6 @@ class OpenHands {
return data.prompt;
}
static async updateConversation(
conversationId: string,
updates: { title: string },
): Promise<boolean> {
const { data } = await openHands.patch<boolean>(
`/api/conversations/${conversationId}`,
updates,
);
return data;
}
}
export default OpenHands;

View File

@@ -50,11 +50,9 @@ export interface GetConfigResponse {
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
};
}
@@ -79,11 +77,7 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
conversation_id: string;
@@ -98,7 +92,6 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -138,9 +131,3 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}

View File

@@ -19,11 +19,8 @@ export function ActionSuggestions({
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
// Use the git_provider from the conversation, not the user's authenticated providers
const currentGitProvider = conversation?.git_provider;
const isGitLab = currentGitProvider === "gitlab";
const isBitbucket = currentGitProvider === "bitbucket";
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
@@ -86,19 +85,21 @@ export function ChatMessage({
/>
</div>
<div className="text-sm break-words">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{message}
</Markdown>
<div className="text-sm break-words flex">
<div>
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
{message}
</Markdown>
</div>
</div>
{children}
</article>

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useTranslation } from "react-i18next";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
@@ -47,7 +46,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) {
ul,
ol,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{defaultMessage}
</Markdown>

View File

@@ -164,7 +164,7 @@ export function EventMessage({
const message = parseMessageFromEvent(event);
return (
<>
<div className="flex flex-col self-end">
<ChatMessage type={event.source} message={message} actions={actions}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
@@ -184,7 +184,7 @@ export function EventMessage({
{isAssistantMessage(event) &&
event.action === "message" &&
renderLikertScale()}
</>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { Trans, useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -200,7 +199,7 @@ export function ExpandableMessage({
ol,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -53,7 +52,7 @@ export function GenericEventMessage({
ul,
ol,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>

View File

@@ -13,7 +13,6 @@ interface ControlsProps {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
@@ -38,8 +37,6 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={setContextMenuOpen}
/>
</div>
);

View File

@@ -35,8 +35,6 @@ interface ConversationCardProps {
conversationStatus?: ConversationStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
@@ -57,11 +55,10 @@ export function ConversationCard({
conversationStatus = "STOPPED",
variant = "default",
conversationId,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
@@ -104,21 +101,21 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDownloadViaVSCode = async (
@@ -144,7 +141,7 @@ export function ConversationCard({
}
}
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -227,15 +224,15 @@ export function ConversationCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenuToggle?.(!contextMenuOpen);
setContextMenuVisible((prev) => !prev);
}}
/>
</div>
)}
<div className="relative">
{contextMenuOpen && (
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => onContextMenuToggle?.(false)}
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"

View File

@@ -12,8 +12,6 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
interface ConversationPanelProps {
onClose: () => void;
@@ -36,15 +34,11 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: updateConversation } = useUpdateConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
@@ -56,20 +50,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
setSelectedConversationId(conversationId);
};
const handleConversationTitleChange = async (
conversationId: string,
newTitle: string,
) => {
updateConversation(
{ conversationId, newTitle },
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.CONVERSATION$TITLE_UPDATED));
},
},
);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation(
@@ -134,9 +114,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
onChangeTitle={(title) =>
handleConversationTitleChange(project.conversation_id, title)
}
title={project.title}
selectedRepository={{
selected_repository: project.selected_repository,
@@ -147,10 +124,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
/>
)}
</NavLink>

View File

@@ -1,98 +0,0 @@
import { DiGit } from "react-icons/di";
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { Container } from "#/components/layout/container";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TabContent } from "#/components/layout/tab-content";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useConversationId } from "#/hooks/use-conversation-id";
import GlobeIcon from "#/icons/globe.svg?react";
import JupyterIcon from "#/icons/jupyter.svg?react";
import OpenHands from "#/api/open-hands";
import TerminalIcon from "#/icons/terminal.svg?react";
export function ConversationTabs() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { conversationId } = useConversationId();
const { t } = useTranslation();
const basePath = `/conversations/${conversationId}`;
return (
<Container
className="h-full w-full"
labels={[
{
label: "Changes",
to: "",
icon: <DiGit className="w-6 h-6" />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.VSCODE$TITLE)}
</div>
),
to: "vscode",
icon: <VscCode className="w-5 h-5" />,
rightContent: !RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
<FaExternalLinkAlt
className="w-3 h-3 text-neutral-400 cursor-pointer"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (conversationId) {
try {
const data = await OpenHands.getVSCodeUrl(conversationId);
if (data.vscode_url) {
const transformedUrl = transformVSCodeUrl(
data.vscode_url,
);
if (transformedUrl) {
window.open(transformedUrl, "_blank");
}
}
} catch (err) {
// Silently handle the error
}
}
}}
/>
) : null,
},
{
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
to: "terminal",
icon: <TerminalIcon />,
},
{ label: "Jupyter", to: "jupyter", icon: <JupyterIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.BROWSER$TITLE)}
</div>
),
to: "browser",
icon: <GlobeIcon />,
},
]}
>
{/* Use both Outlet and TabContent */}
<div className="h-full w-full">
<TabContent conversationPath={basePath} />
</div>
</Container>
);
}

View File

@@ -1,6 +1,5 @@
import React, { useState, useEffect, useContext } from "react";
import { useTranslation } from "react-i18next";
import { FaStar } from "react-icons/fa";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
@@ -208,7 +207,7 @@ export function LikertScale({
className={cn("text-xl transition-all", getButtonClass(rating))}
aria-label={`Rate ${rating} stars`}
>
<FaStar />
{t(I18nKey.FEEDBACK$STAR_RATING)}
</button>
))}
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}

View File

@@ -1,8 +1,7 @@
import React, { ReactNode } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -10,8 +9,6 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -20,8 +17,6 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -31,12 +26,11 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}

View File

@@ -1,19 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
export function BranchErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
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_BRANCHES")}</span>
</div>

View File

@@ -1,22 +1,13 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
export function BranchLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
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_BRANCHES")}</span>

View File

@@ -1,5 +1,4 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { SuggestedTask } from "./task.types";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
@@ -25,25 +24,17 @@ export function TaskCard({ task }: TaskCardProps) {
const { mutate: createConversation, isPending } = useCreateConversation();
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const navigate = useNavigate();
const handleLaunchConversation = () => {
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
return createConversation(
{
repository: {
name: task.repo,
gitProvider: task.git_provider,
},
suggestedTask: task,
return createConversation({
repository: {
name: task.repo,
gitProvider: task.git_provider,
},
{
onSuccess: (data) => {
navigate(`/conversations/${data.conversation_id}`);
},
},
);
suggestedTask: task,
});
};
// Determine the correct URL format based on git provider

View File

@@ -1,26 +0,0 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}

View File

@@ -1,41 +0,0 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (
<button
type="button"
className="text-sm font-normal text-[#8480FF] cursor-pointer"
onClick={handleClick}
>
{t(I18nKey.COMMON$ADD_MICROAGENT)}
</button>
);
}

View File

@@ -1,272 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
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 { 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 {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: (formData: MicroagentFormData) => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// 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);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
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 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 for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
<ModalBackdrop>
<ModalBody className="items-start rounded-[12px] p-6 min-w-[611px]">
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<h2 className="text-white text-xl font-medium">{modalTitle}</h2>
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<button type="button" onClick={onCancel} className="cursor-pointer">
<XIcon width={24} height={24} color="#F9FBFE" />
</button>
</div>
<span className="text-white text-sm font-normal">
{t(I18nKey.MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION)}
</span>
</div>
<form
data-testid="add-microagent-modal"
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
>
{t(I18nKey.MICROAGENT_MANAGEMENT$WHAT_TO_DO)}
<textarea
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</label>
<label
htmlFor="trigger-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
<div className="flex items-center gap-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$ADD_TRIGGERS)}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<BadgeInput
name="trigger-input"
value={triggers}
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
onChange={setTriggers}
/>
<span className="text-xs text-[#ffffff80] font-normal">
{t(
I18nKey.MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS,
)}
</span>
</label>
</form>
<div
className="flex items-center justify-end gap-2 w-full"
onClick={(event) => event.stopPropagation()}
>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
testId="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
>
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}

View File

@@ -1,230 +0,0 @@
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create 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.
`;
export function MicroagentManagementContent() {
// Responsive width state
const [width, setWidth] = useState(window.innerWidth);
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
function handleResize() {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
<MicroagentManagementSidebar isSmallerScreen />
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
<div className="flex-1">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -1,25 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
interface MicroagentManagementLearnThisRepoProps {
repositoryUrl: string;
}
export function MicroagentManagementLearnThisRepo({
repositoryUrl,
}: MicroagentManagementLearnThisRepoProps) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-center rounded-lg bg-[#ffffff0d] border border-dashed border-[#ffffff4d] p-4 hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300 cursor-pointer">
<a
className="text-[16px] font-normal text-[#8480FF]"
href={repositoryUrl}
target="_blank"
rel="noopener noreferrer"
>
{t(I18nKey.MICROAGENT_MANAGEMENT$LEARN_THIS_REPO)}
</a>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
export function MicroagentManagementMain() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
}
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
}

View File

@@ -1,143 +0,0 @@
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 {
setSelectedMicroagentItem,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
interface MicroagentManagementMicroagentCardProps {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
}
export function MicroagentManagementMicroagentCard({
microagent,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
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
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
dispatch(
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: null,
}
: {
microagent: null,
conversation,
},
),
);
dispatch(setSelectedRepository(repository));
};
return (
<div
className={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
</div>
);
}

View File

@@ -1,22 +0,0 @@
import { FaCircleInfo } from "react-icons/fa6";
interface MicroagentManagementNoRepositoriesProps {
title: string;
documentationUrl: string;
}
export function MicroagentManagementNoRepositories({
title,
documentationUrl,
}: MicroagentManagementNoRepositoriesProps) {
return (
<div className="flex items-center justify-center pt-10">
<div className="flex items-center gap-2">
<h2 className="text-white text-sm font-medium">{title}</h2>
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
<FaCircleInfo className="text-primary" />
</a>
</div>
</div>
);
}

View File

@@ -1,47 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementOpeningPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
{t(I18nKey.COMMON$WORKING_ON_IT)}!
</div>
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -1,122 +0,0 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
interface MicroagentManagementRepoMicroagentsProps {
repository: GitRepository;
}
export function MicroagentManagementRepoMicroagents({
repository,
}: MicroagentManagementRepoMicroagentsProps) {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { full_name: repositoryName, git_provider: gitProvider } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
const {
data: microagents,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(repositoryName, "microagent_management", 1000);
useEffect(() => {
const hasConversations = conversations && conversations.length > 0;
const selectedConversation = selectedMicroagentItem?.conversation;
if (hasConversations && selectedConversation) {
// get the latest selected conversation.
const latestSelectedConversation = conversations.find(
(conversation) =>
conversation.conversation_id === selectedConversation.conversation_id,
);
if (latestSelectedConversation) {
dispatch(
setSelectedMicroagentItem({
microagent: null,
conversation: latestSelectedConversation,
}),
);
}
}
}, [conversations]);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<LoadingSpinner size="small" />
</div>
);
}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
/>
</div>
))}
</div>
);
}

View File

@@ -1,119 +0,0 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "repositories") {
return (
<MicroagentManagementNoRepositories
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "organizations") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
)}
documentationUrl={
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
}
/>
);
}
}
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents repository={repository} />
</AccordionItem>
))}
</Accordion>
</div>
);
}

View File

@@ -1,74 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
import { Provider } from "#/types/settings";
import { RootState } from "#/store";
export function MicroagentManagementReviewPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const {
conversation_id: conversationId,
selected_repository: selectedRepository,
git_provider: gitProvider,
pr_number: prNumber,
} = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
</div>
<div className="flex gap-[22px]">
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
<a
href={
selectedRepository && gitProvider && prNumber && prNumber.length > 0
? constructPullRequestUrl(
prNumber[0],
gitProvider,
selectedRepository,
)
: "/#"
}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="primary"
testId="view-conversation-button"
>
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
gitProvider as Provider,
)}`}
</BrandButton>
</a>
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { DOCUMENTATION_URL } from "#/utils/constants";
export function MicroagentManagementSidebarHeader() {
const { t } = useTranslation();
return (
<div>
<h1 className="text-white text-[28px] font-bold">
{t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIPTION)}
</h1>
<p className="text-white text-sm font-normal leading-[20px] pt-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
<a
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
target="_blank"
rel="noopener noreferrer"
>
<QuestionCircleIcon className="inline-block ml-1" />
</a>
</p>
</div>
);
}

View File

@@ -1,49 +0,0 @@
import { Tab, Tabs } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
useSelector((state: RootState) => state.microagentManagement);
return (
<div className="flex w-full flex-col">
<Tabs
aria-label="Options"
classNames={{
base: "py-6",
tabList:
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
tab: "px-2 h-[22px]",
tabContent: "text-white text-[12px] font-normal",
panel: "p-0",
cursor: "bg-[#C9B97480] rounded-sm",
}}
>
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
/>
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
/>
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
/>
</Tab>
</Tabs>
</div>
);
}

View File

@@ -1,71 +0,0 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { cn } from "#/utils/utils";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
}: MicroagentManagementSidebarProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
}, [repositories, dispatch]);
return (
<div
className={cn(
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
>
<MicroagentManagementSidebarHeader />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<LoadingSpinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
)}
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
export function MicroagentManagementViewMicroagentContent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// 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}
`;
};
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>
</div>
);
}

View File

@@ -1,60 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementViewMicroagentHeader() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
// Construct the microagent URL
const microagentUrl = constructMicroagentUrl(
selectedRepository.git_provider,
selectedRepository.full_name,
microagent.path,
);
return (
<div className="flex items-center justify-between pb-2">
<span className="text-sm text-[#ffffff99]">
{selectedRepository.full_name}
</span>
<div className="flex items-center justify-end gap-2">
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
<BrandButton
type="button"
variant="secondary"
testId="edit-in-git-button"
className="py-1 px-2"
>
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
</BrandButton>
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => {}}
testId="learn-button"
className="py-1 px-2"
>
{t(I18nKey.COMMON$LEARN)}
</BrandButton>
</div>
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
export function MicroagentManagementViewMicroagent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
return (
<div className="flex flex-col w-full h-full p-6 overflow-auto">
<MicroagentManagementViewMicroagentHeader />
<span className="text-white text-2xl font-medium pb-2">
{microagent.name}
</span>
<span className="text-white text-lg font-medium pb-6">
{microagent.path}
</span>
<div className="flex-1">
<MicroagentManagementViewMicroagentContent />
</div>
</div>
);
}

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