mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
94 Commits
ray/nix
...
test/repli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c8c1c528f | ||
|
|
bf8b57ba12 | ||
|
|
4c39e92351 | ||
|
|
e65e0a98f0 | ||
|
|
eecc00fa4a | ||
|
|
5654e251a8 | ||
|
|
d9694aabcd | ||
|
|
bc8ef37192 | ||
|
|
5f141f7712 | ||
|
|
30e3011cb0 | ||
|
|
3475d8021b | ||
|
|
32cd50db2f | ||
|
|
f0a6db936c | ||
|
|
11c37d8d70 | ||
|
|
7e1367057a | ||
|
|
3bbb0c6279 | ||
|
|
eed71c21bd | ||
|
|
4f46826de9 | ||
|
|
ea50fe4e3c | ||
|
|
b057af8d63 | ||
|
|
fba2218760 | ||
|
|
6147cbdc18 | ||
|
|
802acb3c7e | ||
|
|
376dc21e34 | ||
|
|
387318385c | ||
|
|
553f0a0918 | ||
|
|
0d1e21ae45 | ||
|
|
a885e9e4d2 | ||
|
|
4c10848e8d | ||
|
|
1d95b01514 | ||
|
|
cd32b5508c | ||
|
|
9a3bf0f2aa | ||
|
|
1d04a83e08 | ||
|
|
17e9b0fd6a | ||
|
|
54986c9841 | ||
|
|
c419277326 | ||
|
|
35b945b9d1 | ||
|
|
5c3619bc48 | ||
|
|
641d0a0bcb | ||
|
|
fbadea9a6f | ||
|
|
6e25d4bbb6 | ||
|
|
127220dc39 | ||
|
|
9a291e385b | ||
|
|
95ccec82d9 | ||
|
|
4aaa2ccd39 | ||
|
|
bfe0aa08b6 | ||
|
|
7fb47761c6 | ||
|
|
415931b4dc | ||
|
|
6d57eeb3ed | ||
|
|
c03d390772 | ||
|
|
a266d4274a | ||
|
|
a19cd193d9 | ||
|
|
4f3e648379 | ||
|
|
b99150c616 | ||
|
|
8937b3fbfc | ||
|
|
fb5a39a150 | ||
|
|
fc11c15b75 | ||
|
|
50a8741d50 | ||
|
|
9388fef0ef | ||
|
|
050e80cc34 | ||
|
|
5cc47ee592 | ||
|
|
a09346672f | ||
|
|
9e72b69cf8 | ||
|
|
da1f3a5a7b | ||
|
|
5c27a452ac | ||
|
|
8cb1c738ff | ||
|
|
cf276b2e96 | ||
|
|
1f416f616c | ||
|
|
52775acd4d | ||
|
|
be0596abd6 | ||
|
|
e77957aa92 | ||
|
|
d04c4c493e | ||
|
|
5cb534217a | ||
|
|
9331f5e8a7 | ||
|
|
8d16567428 | ||
|
|
acc69b74c5 | ||
|
|
28d174a7ce | ||
|
|
cff5697456 | ||
|
|
794eedf503 | ||
|
|
a6ffb2f799 | ||
|
|
3be3779f68 | ||
|
|
222f5fdd51 | ||
|
|
2066f90654 | ||
|
|
9ee2f976a1 | ||
|
|
be62df5277 | ||
|
|
4baf2a64c1 | ||
|
|
2a833325e1 | ||
|
|
aa2cacab44 | ||
|
|
ea07570f62 | ||
|
|
3f5a5005a2 | ||
|
|
7acee9e5da | ||
|
|
37cbeb735f | ||
|
|
c6c6c202f6 | ||
|
|
517a72fd0d |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @rbren @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
8
.github/workflows/fe-unit-tests.yml
vendored
8
.github/workflows/fe-unit-tests.yml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/fe-unit-tests.yml'
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-unit-tests.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:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
node-version: 22
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Run TypeScript compilation
|
||||
working-directory: ./frontend
|
||||
run: npm run make-i18n && tsc
|
||||
run: npm run build
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
|
||||
128
.github/workflows/ghcr-build.yml
vendored
128
.github/workflows/ghcr-build.yml
vendored
@@ -40,7 +40,8 @@ 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: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
|
||||
{ image: "ubuntu:24.04", tag: "ubuntu" }
|
||||
]')
|
||||
else
|
||||
json=$(jq -n -c '[
|
||||
@@ -58,9 +59,6 @@ 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
|
||||
@@ -86,24 +84,11 @@ 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-4vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -130,22 +115,13 @@ 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'
|
||||
- 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
|
||||
cache: poetry
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
|
||||
- name: Create source distribution and Dockerfile
|
||||
@@ -190,61 +166,6 @@ 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)
|
||||
@@ -276,25 +197,17 @@ jobs:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- 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: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
cache: 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
|
||||
@@ -346,25 +259,17 @@ jobs:
|
||||
load: true
|
||||
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
context: containers/runtime
|
||||
- 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: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
cache: 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
|
||||
@@ -391,7 +296,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, verify_hash_equivalence_in_runtime_and_app]
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: All tests passed
|
||||
run: echo "All runtime tests have passed successfully!"
|
||||
@@ -400,7 +305,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, verify_hash_equivalence_in_runtime_and_app]
|
||||
needs: [test_runtime_root, test_runtime_oh]
|
||||
steps:
|
||||
- name: Some tests failed
|
||||
run: |
|
||||
@@ -425,6 +330,7 @@ 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 \
|
||||
|
||||
6
.github/workflows/integration-runner.yml
vendored
6
.github/workflows/integration-runner.yml
vendored
@@ -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
|
||||
run: poetry install --with dev,test,runtime,evaluation
|
||||
|
||||
- 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 5318 as fallback issue number for manual triggers
|
||||
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5318 }}
|
||||
# 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 }}
|
||||
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' }}
|
||||
|
||||
6
.github/workflows/lint-fix.yml
vendored
6
.github/workflows/lint-fix.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
@@ -7,7 +7,7 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Run pre-commit hooks
|
||||
|
||||
108
.github/workflows/npm-publish-ui.yml
vendored
Normal file
108
.github/workflows/npm-publish-ui.yml
vendored
Normal file
@@ -0,0 +1,108 @@
|
||||
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"
|
||||
34
.github/workflows/ui-build.yml
vendored
Normal file
34
.github/workflows/ui-build.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -182,6 +182,8 @@ cython_debug/
|
||||
.roo/rules
|
||||
.cline/rules
|
||||
.windsurf/rules
|
||||
.repomix
|
||||
repomix-output.txt
|
||||
|
||||
# evaluation
|
||||
evaluation/evaluation_outputs
|
||||
|
||||
@@ -137,3 +137,65 @@ 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
|
||||
|
||||
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #frontend channel in our Slack
|
||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
|
||||
@@ -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.48-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.49-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-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.48
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-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.48
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-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.48
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
# Cache directory path
|
||||
#cache_dir = "/tmp/cache"
|
||||
|
||||
# Reasoning effort for o1 models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Debugging enabled
|
||||
#debug = false
|
||||
|
||||
@@ -119,6 +116,9 @@ api_key = ""
|
||||
# API version
|
||||
#api_version = ""
|
||||
|
||||
# Reasoning effort for OpenAI o-series models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Cost per input token
|
||||
#input_cost_per_token = 0.0
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ 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
|
||||
|
||||
|
||||
@@ -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.48-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.49-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-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:
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
{
|
||||
"group": "Integrations",
|
||||
"pages": [
|
||||
"usage/cloud/bitbucket-installation",
|
||||
"usage/cloud/github-installation",
|
||||
"usage/cloud/gitlab-installation",
|
||||
"usage/cloud/slack-installation"
|
||||
@@ -66,7 +67,9 @@
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1827,6 +1827,11 @@
|
||||
"updated_at": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"owner_type": {
|
||||
"type": "string",
|
||||
"enum": ["user", "organization"],
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
BIN
docs/static/img/connect-repo-no-github.png
vendored
Normal file
BIN
docs/static/img/connect-repo-no-github.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/openhands-llm-api-key.png
vendored
Normal file
BIN
docs/static/img/openhands-llm-api-key.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/static/img/openhands-provider-cli.png
vendored
Normal file
BIN
docs/static/img/openhands-provider-cli.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
25
docs/usage/cloud/bitbucket-installation.mdx
Normal file
25
docs/usage/cloud/bitbucket-installation.mdx
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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!
|
||||
|
||||

|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.
|
||||
@@ -9,8 +9,9 @@ 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) or
|
||||
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
|
||||
- [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.
|
||||
- See `Suggested Tasks` for repositories that OpenHands has access to.
|
||||
- Launch an empty conversation using `Launch from Scratch`.
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||

|
||||

|
||||
|
||||
## Using Tokens with Reduced Scopes
|
||||
|
||||
|
||||
@@ -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 or GitLab account:
|
||||
You'll be prompted to connect with your GitHub, GitLab or Bitbucket account:
|
||||
|
||||
1. Click `Log in with GitHub` or `Log in with GitLab`.
|
||||
1. Click `Log in with GitHub`, `Log in with GitLab` or `Log in with Bitbucket`.
|
||||
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,5 +22,6 @@ 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.
|
||||
|
||||
@@ -8,6 +8,12 @@ description: This page outlines all available configuration options for OpenHand
|
||||
In GUI Mode, any settings applied through the Settings UI will take precedence.
|
||||
</Note>
|
||||
|
||||
## Location of the `config.toml` File
|
||||
|
||||
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
|
||||
located in the same directory from which the command is run. Alternatively, you may use the `--config-file` option to
|
||||
specify a different path to the `config.toml` file.
|
||||
|
||||
## Core Configuration
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
@@ -12,7 +12,8 @@ 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**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
|
||||
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.
|
||||
|
||||
### Can I use OpenHands for production workloads?
|
||||
|
||||
|
||||
@@ -33,6 +33,45 @@ pip install openhands-ai
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Create shell aliases for easy access across environments">
|
||||
|
||||
Add the following to your shell configuration file (`.bashrc`, `.zshrc`, etc.):
|
||||
|
||||
```bash
|
||||
# Add OpenHands aliases
|
||||
alias openhands="uvx --python 3.12 --from openhands-ai openhands"
|
||||
alias oh="uvx --python 3.12 --from openhands-ai openhands"
|
||||
```
|
||||
|
||||
After adding these lines, reload your shell configuration with `source ~/.bashrc` or `source ~/.zshrc` (depending on your shell).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Install OpenHands in home directory without global installation">
|
||||
|
||||
You can install OpenHands in a virtual environment in your home directory using `uv`:
|
||||
|
||||
```bash
|
||||
# Create a virtual environment in your home directory
|
||||
cd ~
|
||||
uv venv .openhands-venv --python 3.12
|
||||
|
||||
# Install OpenHands in the virtual environment
|
||||
uv pip install -t ~/.openhands-venv/lib/python3.12/site-packages openhands-ai
|
||||
|
||||
# Add the bin directory to your PATH in your shell configuration file
|
||||
echo 'export PATH="$PATH:$HOME/.openhands-venv/bin"' >> ~/.bashrc # or ~/.zshrc
|
||||
|
||||
# Reload your shell configuration
|
||||
source ~/.bashrc # or source ~/.zshrc
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
2. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
openhands
|
||||
@@ -64,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.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,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.48 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
@@ -84,7 +123,8 @@ docker run -it \
|
||||
|
||||
This launches the CLI in Docker, allowing you to interact with OpenHands.
|
||||
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
|
||||
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
The conversation history will be saved in `~/.openhands/sessions`.
|
||||
|
||||
|
||||
@@ -25,7 +25,8 @@ 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) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
|
||||
- [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).
|
||||
- Set application settings like your preferred language, notifications and other preferences.
|
||||
- [Manage custom secrets](/usage/common-settings#secrets-management).
|
||||
|
||||
@@ -122,17 +123,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### BitBucket Setup (Coming soon ...)
|
||||
#### BitBucket Setup
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting Up a BitBucket Password">
|
||||
1. **Generate an App Password**:
|
||||
- On BitBucket, go to Personal Settings > App Password.
|
||||
- Create a new password with the following scopes:
|
||||
- `repository: read`
|
||||
- `account`: `read`
|
||||
- `repository: write`
|
||||
- `pull requests: read`
|
||||
- `pull requests: write`
|
||||
- `issues: read`
|
||||
- `issues: write`
|
||||
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
|
||||
2. **Enter Token in OpenHands**:
|
||||
|
||||
@@ -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.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,13 +73,14 @@ 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.48 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
|
||||
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 user’s
|
||||
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
|
||||
|
||||
## Additional Options
|
||||
|
||||
@@ -90,6 +91,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, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
|
||||
Run `poetry run python -m openhands.core.main --help` for all options.
|
||||
|
||||
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
|
||||
|
||||
@@ -10,7 +10,8 @@ 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:
|
||||
|
||||
@@ -20,6 +21,7 @@ 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!
|
||||
@@ -70,17 +72,20 @@ 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](configuration-options#llm-customization).
|
||||
For detailed information about model customization, see
|
||||
[LLM Configuration Options](/usage/configuration-options#llm-configuration).
|
||||
|
||||
### API retries and rate limits
|
||||
|
||||
|
||||
@@ -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.48-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-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.48
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
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.48
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
|
||||
25
docs/usage/llms/moonshot.mdx
Normal file
25
docs/usage/llms/moonshot.mdx
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
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.
|
||||
|
||||
34
docs/usage/llms/openhands-llms.mdx
Normal file
34
docs/usage/llms/openhands-llms.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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`.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
@@ -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.48-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-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.48
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.49
|
||||
```
|
||||
|
||||
> **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.
|
||||
|
||||
@@ -29,6 +29,15 @@ 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
|
||||
@@ -57,6 +66,22 @@ 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:
|
||||
@@ -84,7 +109,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 servers.
|
||||
2. Connects to any configured SSE and SHTTP servers.
|
||||
3. Starts any configured stdio servers.
|
||||
4. Registers the tools provided by these servers with the agent.
|
||||
|
||||
@@ -93,3 +118,23 @@ 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.
|
||||
|
||||
@@ -24,3 +24,12 @@ General microagent file example for organization `Great-Co` located inside the `
|
||||
```
|
||||
|
||||
For GitLab organizations, the same microagent would be located inside the `openhands-config` repository.
|
||||
|
||||
## User Microagents When Running Openhands on Your Own
|
||||
|
||||
<Note>
|
||||
This works with CLI, headless and development modes. It does not work out of the box when running OpenHands using the docker command.
|
||||
</Note>
|
||||
|
||||
When running OpenHands on your own, you can place microagents in the `~/.openhands/microagents` folder on your local
|
||||
system and OpenHands will always load it for all your conversations.
|
||||
|
||||
@@ -101,13 +101,14 @@ 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/ml_bench`](./benchmarks/ml_bench)
|
||||
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ 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
|
||||
|
||||
@@ -17,6 +17,7 @@ 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,
|
||||
@@ -843,3 +844,5 @@ 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)
|
||||
|
||||
@@ -38,6 +38,10 @@ 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
|
||||
|
||||
45
evaluation/benchmarks/swe_bench/loc_eval/README.md
Normal file
45
evaluation/benchmarks/swe_bench/loc_eval/README.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# **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`
|
||||
1006
evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py
Normal file
1006
evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py
Normal file
File diff suppressed because it is too large
Load Diff
1110
evaluation/benchmarks/swe_bench/loc_eval/loc_utils.py
Normal file
1110
evaluation/benchmarks/swe_bench/loc_eval/loc_utils.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ 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,
|
||||
@@ -109,9 +110,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
|
||||
template_name = 'swt.j2'
|
||||
elif mode == 'swe':
|
||||
if 'claude' in llm_model:
|
||||
template_name = 'swe_claude.j2'
|
||||
elif 'gemini' in llm_model:
|
||||
template_name = 'swe_gemini.j2'
|
||||
template_name = 'swe_default.j2'
|
||||
elif 'gpt-4.1' in llm_model:
|
||||
template_name = 'swe_gpt4.j2'
|
||||
else:
|
||||
@@ -970,3 +969,5 @@ 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)
|
||||
|
||||
227
evaluation/benchmarks/swe_bench/scripts/eval_localization.sh
Executable file
227
evaluation/benchmarks/swe_bench/scripts/eval_localization.sh
Executable file
@@ -0,0 +1,227 @@
|
||||
#!/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
|
||||
31
evaluation/benchmarks/terminal_bench/README.md
Normal file
31
evaluation/benchmarks/terminal_bench/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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.
|
||||
@@ -25,7 +25,8 @@ class Test(BaseIntegrationTest):
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# git add
|
||||
action = CmdRunAction(command='git add hello.py .vscode/')
|
||||
cmd_str = 'git add hello.py'
|
||||
action = CmdRunAction(command=cmd_str)
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
@@ -40,15 +41,6 @@ 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)
|
||||
|
||||
@@ -311,6 +311,76 @@ 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,
|
||||
@@ -363,11 +433,26 @@ def _process_instance_wrapper(
|
||||
+ f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]'
|
||||
+ '-' * 10
|
||||
)
|
||||
# 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
|
||||
|
||||
# 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
|
||||
msg = (
|
||||
'-' * 10
|
||||
+ '\n'
|
||||
@@ -456,6 +541,10 @@ 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
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@tanstack/query/recommended",
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"plugins": ["prettier", "unused-imports", "i18next"],
|
||||
"rules": {
|
||||
"i18next/no-literal-string": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npm run lint
|
||||
npm run check-translation-completeness
|
||||
npx lint-staged
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
|
||||
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.todo("should render an assistant message");
|
||||
|
||||
it.skip("should support code syntax highlighting", () => {
|
||||
it("should support code syntax highlighting", () => {
|
||||
const code = "```js\nconsole.log('Hello, World!')\n```";
|
||||
render(<ChatMessage type="user" message={code} />);
|
||||
|
||||
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an error toast if copying content to clipboard fails", async () => {});
|
||||
|
||||
it("should render a component passed as a prop", () => {
|
||||
function Component() {
|
||||
return <div data-testid="custom-component">Custom Component</div>;
|
||||
|
||||
@@ -44,4 +44,64 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
useParams: vi.fn().mockReturnValue({
|
||||
conversationId: "123",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useHandleRuntimeActive hook
|
||||
vi.mock("#/hooks/use-handle-runtime-active", () => ({
|
||||
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
|
||||
}));
|
||||
|
||||
// Mock the useMicroagentPrompt hook
|
||||
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
|
||||
useMicroagentPrompt: vi.fn().mockReturnValue({
|
||||
data: "Generated prompt",
|
||||
isLoading: false
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useGetMicroagents hook
|
||||
vi.mock("#/hooks/query/use-get-microagents", () => ({
|
||||
useGetMicroagents: vi.fn().mockReturnValue({
|
||||
data: ["file1", "file2"]
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useTranslation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
|
||||
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
|
||||
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
|
||||
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
|
||||
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
|
||||
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
|
||||
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
|
||||
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
|
||||
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
|
||||
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
}));
|
||||
|
||||
describe("LaunchMicroagentModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const onLaunchMock = vi.fn();
|
||||
const eventId = 12;
|
||||
const conversationId = "123";
|
||||
|
||||
const renderMicroagentModal = (
|
||||
{ isLoading }: { isLoading: boolean } = { isLoading: false },
|
||||
) =>
|
||||
render(
|
||||
<LaunchMicroagentModal
|
||||
onClose={onCloseMock}
|
||||
onLaunch={onLaunchMock}
|
||||
eventId={eventId}
|
||||
selectedRepo="some-repo"
|
||||
isLoading={isLoading}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the launch microagent modal", () => {
|
||||
renderMicroagentModal();
|
||||
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the form fields", () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// inputs
|
||||
screen.getByTestId("query-input");
|
||||
screen.getByTestId("target-input");
|
||||
screen.getByTestId("trigger-input");
|
||||
|
||||
// action buttons
|
||||
screen.getByRole("button", { name: "Launch" });
|
||||
screen.getByRole("button", { name: "Cancel" });
|
||||
});
|
||||
|
||||
it("should call onClose when pressing the cancel button", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the prompt from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const descriptionInput = screen.getByTestId("query-input");
|
||||
expect(descriptionInput).toHaveValue("Generated prompt");
|
||||
});
|
||||
|
||||
it("should display the list of microagent files from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
expect(targetInput).toHaveValue("");
|
||||
|
||||
await userEvent.click(targetInput);
|
||||
|
||||
expect(screen.getByText("file1")).toBeInTheDocument();
|
||||
expect(screen.getByText("file2")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
expect(targetInput).toHaveValue("file1");
|
||||
});
|
||||
|
||||
it("should call onLaunch with the form data", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const triggerInput = screen.getByTestId("trigger-input");
|
||||
await userEvent.type(triggerInput, "trigger1 ");
|
||||
await userEvent.type(triggerInput, "trigger2 ");
|
||||
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
await userEvent.click(targetInput);
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
|
||||
const launchButton = await screen.findByRole("button", { name: "Launch" });
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
|
||||
"trigger1",
|
||||
"trigger2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should disable the launch button if isLoading is true", async () => {
|
||||
renderMicroagentModal({ isLoading: true });
|
||||
|
||||
const launchButton = screen.getByRole("button", { name: "Launch" });
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Messages } from "#/components/features/chat/messages";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
OpenHandsAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderMessages = ({
|
||||
messages,
|
||||
}: {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
}) => {
|
||||
const { rerender, ...rest } = render(
|
||||
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderMessages = (
|
||||
newMessages: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) => {
|
||||
rerender(
|
||||
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
|
||||
);
|
||||
};
|
||||
|
||||
return { ...rest, rerender: rerenderMessages };
|
||||
};
|
||||
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
id: 0,
|
||||
action: "message",
|
||||
source: "agent",
|
||||
message: "Hello, Assistant!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: {
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
thought: "",
|
||||
wait_for_response: false,
|
||||
},
|
||||
};
|
||||
|
||||
const userMessage: UserMessageAction = {
|
||||
id: 1,
|
||||
action: "message",
|
||||
source: "user",
|
||||
message: "Hello, User!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
renderMessages({ messages: [userMessage, assistantMessage] });
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
const mockConversation: Conversation = {
|
||||
conversation_id: "123",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
created_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
selected_branch: null,
|
||||
selected_repository: null,
|
||||
git_provider: "github",
|
||||
session_api_key: null,
|
||||
url: null,
|
||||
};
|
||||
|
||||
getConversationSpy.mockResolvedValue(mockConversation);
|
||||
|
||||
renderMessages({
|
||||
messages: [userMessage, assistantMessage],
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -529,4 +529,287 @@ 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
|
||||
t: (key: string) => {
|
||||
// Return a mock translation for the test
|
||||
const translations: Record<string, string> = {
|
||||
"HOME$LETS_START_BUILDING": "Let's start building",
|
||||
"HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
|
||||
"HOME$LOADING": "Loading...",
|
||||
"HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
|
||||
"HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
|
||||
"HOME$READ_THIS": "Read this"
|
||||
HOME$LETS_START_BUILDING: "Let's start building",
|
||||
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
|
||||
HOME$LOADING: "Loading...",
|
||||
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
|
||||
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
|
||||
HOME$READ_THIS: "Read this",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -206,9 +206,8 @@ describe("RepoConnector", () => {
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@@ -252,8 +257,6 @@ describe("RepositorySelectionForm", () => {
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(
|
||||
MOCK_SEARCH_REPOS[0].full_name,
|
||||
);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,9 +88,14 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
title: "Task 1",
|
||||
},
|
||||
undefined,
|
||||
MOCK_TASK_1,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
@@ -105,4 +110,29 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
|
||||
describe("BadgeInput", () => {
|
||||
it("should render the values", () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
|
||||
|
||||
expect(screen.getByText("test")).toBeInTheDocument();
|
||||
expect(screen.getByText("test2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the input's as a badge on space", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "test");
|
||||
await userEvent.type(input, " ");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on backspace", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "{backspace}");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on click", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const removeButton = screen.getByTestId("remove-button");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should not create empty badges", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={[]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, " ");
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MicroagentStatusIndicator", () => {
|
||||
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should show default completed message when status is completed but no PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "MICROAGENT$STATUS_COMPLETED",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
|
||||
});
|
||||
|
||||
it("should show creating status without PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.CREATING}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error status", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.ERROR}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prioritize PR URL over conversation link when both are provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
// Should not link to conversation when PR URL is available
|
||||
expect(link).not.toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/test-conversation",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with GitLab MR URLs", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
});
|
||||
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractPRUrls,
|
||||
containsPRUrl,
|
||||
getFirstPRUrl,
|
||||
} from "#/utils/parse-pr-url";
|
||||
|
||||
describe("parse-pr-url", () => {
|
||||
describe("extractPRUrls", () => {
|
||||
it("should extract GitHub PR URLs", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should extract GitLab MR URLs", () => {
|
||||
const text =
|
||||
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Bitbucket PR URLs", () => {
|
||||
const text =
|
||||
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://bitbucket.org/owner/repo/pull-requests/789",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Azure DevOps PR URLs", () => {
|
||||
const text =
|
||||
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract multiple PR URLs", () => {
|
||||
const text = `
|
||||
GitHub: https://github.com/owner/repo/pull/123
|
||||
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls).toContain("https://github.com/owner/repo/pull/123");
|
||||
expect(urls).toContain(
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle self-hosted GitLab URLs", () => {
|
||||
const text =
|
||||
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no PR URLs found", () => {
|
||||
const text = "This is just regular text with no PR URLs";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle URLs with HTTP instead of HTTPS", () => {
|
||||
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should remove duplicate URLs", () => {
|
||||
const text = `
|
||||
Same PR mentioned twice:
|
||||
https://github.com/owner/repo/pull/123
|
||||
https://github.com/owner/repo/pull/123
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("containsPRUrl", () => {
|
||||
it("should return true when PR URL is present", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
expect(containsPRUrl(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no PR URL is present", () => {
|
||||
const text = "This is just regular text";
|
||||
expect(containsPRUrl(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstPRUrl", () => {
|
||||
it("should return the first PR URL found", () => {
|
||||
const text = `
|
||||
First: https://github.com/owner/repo/pull/123
|
||||
Second: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/123");
|
||||
});
|
||||
|
||||
it("should return null when no PR URL is found", () => {
|
||||
const text = "This is just regular text";
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle typical microagent finish messages", () => {
|
||||
const text = `
|
||||
I have successfully created a pull request with the requested changes.
|
||||
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
|
||||
|
||||
The changes include:
|
||||
- Updated the component
|
||||
- Added tests
|
||||
- Fixed the issue
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
|
||||
});
|
||||
|
||||
it("should handle messages with PR URLs in the middle", () => {
|
||||
const text = `
|
||||
Task completed successfully! I've created a pull request at
|
||||
https://github.com/owner/repo/pull/567 with all the requested changes.
|
||||
Please review when you have a chance.
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/567");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the i18next hook
|
||||
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
})(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
|
||||
path: "/settings/app",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="credits-settings-screen" />,
|
||||
path: "/settings/credits",
|
||||
Component: () => <div data-testid="billing-settings-screen" />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = (path = "/settings") => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={[path]} />, {
|
||||
const renderSettingsScreen = (path = "/settings") =>
|
||||
render(<RouterStub initialEntries={[path]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const sectionsToExclude = ["api keys", "credits", "billing"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
|
||||
const sectionsToInclude = [
|
||||
"integrations",
|
||||
"application",
|
||||
"credits",
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not be able to access oss-restricted routes in oss", async () => {
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const { rerender } = renderSettingsScreen("/settings/credits");
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
// In OSS mode, accessing restricted routes should redirect to /settings
|
||||
// Since createRoutesStub doesn't handle clientLoader redirects properly,
|
||||
// we test that the correct navbar is shown (OSS navbar) and that
|
||||
// the restricted route components are not rendered when accessing /settings
|
||||
renderSettingsScreen("/settings");
|
||||
|
||||
// Verify we're in OSS mode by checking the navbar
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("credits-settings-screen"),
|
||||
within(navbar).queryByText("credits", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
|
||||
// Verify the LLM settings screen is shown
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.todo("should not be able to access saas-restricted routes in saas");
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
});
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Check for hardcoded English strings in Home components", () => {
|
||||
test("HomeHeader should not have hardcoded English strings", () => {
|
||||
const { container } = render(<HomeHeader />);
|
||||
|
||||
// Get all text content
|
||||
const text = container.textContent;
|
||||
|
||||
// List of English strings that should be translated
|
||||
const hardcodedStrings = [
|
||||
"Launch from Scratch",
|
||||
"Read this",
|
||||
];
|
||||
|
||||
// Check each string
|
||||
hardcodedStrings.forEach((str) => {
|
||||
expect(text).not.toContain(str);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -82,17 +82,5 @@ 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: "/",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,14 +52,16 @@ 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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
2264
frontend/package-lock.json
generated
2264
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,39 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.48.0",
|
||||
"version": "0.49.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0-beta.13",
|
||||
"@heroui/react": "^2.8.1",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.3",
|
||||
"@react-router/serve": "^7.6.3",
|
||||
"@react-router/node": "^7.7.0",
|
||||
"@react-router/serve": "^7.7.0",
|
||||
"@react-types/shared": "^3.29.1",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.7.0",
|
||||
"@stripe/stripe-js": "^7.4.0",
|
||||
"@stripe/stripe-js": "^7.5.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.81.4",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@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.0",
|
||||
"i18next": "^25.3.1",
|
||||
"framer-motion": "^12.23.6",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.256.2",
|
||||
"posthog-js": "^1.257.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -42,14 +42,15 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.6.3",
|
||||
"react-router": "^7.7.0",
|
||||
"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.2",
|
||||
"vite": "^7.0.5",
|
||||
"web-vitals": "^5.0.3",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -70,7 +71,6 @@
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
|
||||
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -82,17 +82,17 @@
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@babel/types": "^7.28.1",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.2",
|
||||
"@react-router/dev": "^7.6.3",
|
||||
"@playwright/test": "^1.54.1",
|
||||
"@react-router/dev": "^7.7.0",
|
||||
"@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.0.10",
|
||||
"@types/node": "^24.0.14",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -107,6 +107,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"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.1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.10.2'
|
||||
const PACKAGE_VERSION = '2.10.3'
|
||||
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-commit hook script to check for unlocalized strings in the frontend code
|
||||
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const parser = require('@babel/parser');
|
||||
const traverse = require('@babel/traverse').default;
|
||||
|
||||
// Files/directories to ignore
|
||||
const IGNORE_PATHS = [
|
||||
// Build and dependency files
|
||||
"node_modules",
|
||||
"dist",
|
||||
".git",
|
||||
"test",
|
||||
"__tests__",
|
||||
".d.ts",
|
||||
"i18n",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
|
||||
// Internal code that doesn't need localization
|
||||
"mocks", // Mock data
|
||||
"assets", // SVG paths and CSS classes
|
||||
"types", // Type definitions and constants
|
||||
"state", // Redux state management
|
||||
"api", // API endpoints
|
||||
"services", // Internal services
|
||||
"hooks", // React hooks
|
||||
"context", // React context
|
||||
"store", // Redux store
|
||||
"routes.ts", // Route definitions
|
||||
"root.tsx", // Root component
|
||||
"entry.client.tsx", // Client entry point
|
||||
"utils/scan-unlocalized-strings.ts", // Original scanner
|
||||
"utils/scan-unlocalized-strings-ast.ts", // This file itself
|
||||
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
|
||||
];
|
||||
|
||||
// Extensions to scan
|
||||
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"allow",
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"href",
|
||||
"src",
|
||||
"rel",
|
||||
"target",
|
||||
"style",
|
||||
"onClick",
|
||||
"onChange",
|
||||
"onSubmit",
|
||||
"data-testid",
|
||||
"aria-labelledby",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"role",
|
||||
"sandbox",
|
||||
];
|
||||
|
||||
function shouldIgnorePath(filePath) {
|
||||
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
|
||||
}
|
||||
|
||||
// Check if a string looks like a translation key
|
||||
// Translation keys typically use dots, underscores, or are all caps
|
||||
// Also check for the pattern with $ which is used in our translation keys
|
||||
function isLikelyTranslationKey(str) {
|
||||
return (
|
||||
/^[A-Z0-9_$.]+$/.test(str) ||
|
||||
str.includes(".") ||
|
||||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a string is a raw translation key that should be wrapped in t()
|
||||
function isRawTranslationKey(str) {
|
||||
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
|
||||
// Exclude specific keys that are already properly used with i18next.t() in the code
|
||||
const excludedKeys = [
|
||||
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
|
||||
"ERROR$GENERIC",
|
||||
"GITHUB$AUTH_SCOPE",
|
||||
];
|
||||
|
||||
if (excludedKeys.includes(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
|
||||
}
|
||||
|
||||
// Specific technical strings that should be excluded from localization
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
"STATUS$READY",
|
||||
"STATUS$STOPPED",
|
||||
"STATUS$ERROR",
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
|
||||
}
|
||||
|
||||
function isLikelyCode(str) {
|
||||
// A string with no spaces and at least one underscore or colon is likely a code.
|
||||
// (e.g.: "browser_interactive" or "error:")
|
||||
if (str.includes(" ")) {
|
||||
return false
|
||||
}
|
||||
if (str.includes(":") || str.includes("_")){
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCommonDevelopmentString(str) {
|
||||
|
||||
// Technical patterns that are definitely not UI strings
|
||||
const technicalPatterns = [
|
||||
// URLs and paths
|
||||
/^https?:\/\//, // URLs
|
||||
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
|
||||
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
|
||||
/^@[a-zA-Z0-9/-]+$/, // Import paths
|
||||
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
|
||||
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
|
||||
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
|
||||
/^application\/[a-zA-Z0-9-]+$/, // MIME types
|
||||
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
|
||||
|
||||
// Numbers, IDs, and technical values
|
||||
/^\d+(\.\d+)?$/, // Numbers
|
||||
/^#[0-9a-fA-F]{3,8}$/, // Color codes
|
||||
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
|
||||
/^mm:ss$/, // Time format
|
||||
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
|
||||
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
|
||||
/^[A-Za-z0-9+/=]+$/, // Base64
|
||||
|
||||
// HTML and CSS selectors
|
||||
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
|
||||
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
|
||||
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
|
||||
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
|
||||
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
|
||||
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
|
||||
|
||||
// CSS and styling patterns
|
||||
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
|
||||
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
|
||||
];
|
||||
|
||||
// File extensions and media types
|
||||
const fileExtensionPattern =
|
||||
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
|
||||
if (fileExtensionPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI model and provider patterns
|
||||
const aiRelatedPattern =
|
||||
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
|
||||
if (aiRelatedPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CSS units and values
|
||||
const cssUnitsPattern =
|
||||
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
|
||||
const cssValuesPattern =
|
||||
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
|
||||
|
||||
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with brackets (common in the codebase)
|
||||
if (
|
||||
str.includes("[") &&
|
||||
str.includes("]") &&
|
||||
(str.includes("px") ||
|
||||
str.includes("rem") ||
|
||||
str.includes("em") ||
|
||||
str.includes("w-") ||
|
||||
str.includes("h-") ||
|
||||
str.includes("p-") ||
|
||||
str.includes("m-"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with specific patterns
|
||||
if (
|
||||
str.includes("border-") ||
|
||||
str.includes("rounded-") ||
|
||||
str.includes("cursor-") ||
|
||||
str.includes("opacity-") ||
|
||||
str.includes("disabled:") ||
|
||||
str.includes("hover:") ||
|
||||
str.includes("focus-within:") ||
|
||||
str.includes("first-of-type:") ||
|
||||
str.includes("last-of-type:") ||
|
||||
str.includes("group-data-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it looks like a Tailwind class string
|
||||
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
|
||||
// Common Tailwind prefixes and patterns
|
||||
const tailwindPrefixes = [
|
||||
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
|
||||
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
|
||||
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
|
||||
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
|
||||
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
|
||||
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
|
||||
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
|
||||
];
|
||||
|
||||
// Check if any word in the string starts with a Tailwind prefix
|
||||
const words = str.split(/\s+/);
|
||||
for (const word of words) {
|
||||
for (const prefix of tailwindPrefixes) {
|
||||
if (word.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Tailwind modifiers
|
||||
const tailwindModifiers = [
|
||||
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
|
||||
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
|
||||
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
|
||||
];
|
||||
|
||||
for (const word of words) {
|
||||
for (const modifier of tailwindModifiers) {
|
||||
if (word.includes(modifier)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CSS property combinations
|
||||
const cssProperties = [
|
||||
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
|
||||
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
|
||||
];
|
||||
|
||||
// If the string contains multiple CSS properties, it's likely a CSS class string
|
||||
let cssPropertyCount = 0;
|
||||
for (const word of words) {
|
||||
if (
|
||||
cssProperties.some(
|
||||
(prop) => word === prop || word.startsWith(`${prop}-`),
|
||||
)
|
||||
) {
|
||||
cssPropertyCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cssPropertyCount >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for specific CSS class patterns that appear in the test failures
|
||||
if (
|
||||
str.match(
|
||||
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTML tags and attributes
|
||||
if (
|
||||
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
|
||||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific patterns in suggestions and examples
|
||||
if (
|
||||
str.includes("* ") &&
|
||||
(str.includes("create a") ||
|
||||
str.includes("build a") ||
|
||||
str.includes("make a"))
|
||||
) {
|
||||
// This is likely a suggestion or example, not a UI string
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for specific technical identifiers from the test failures
|
||||
if (
|
||||
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
|
||||
str,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for URL paths and query parameters
|
||||
if (
|
||||
str.startsWith("?") ||
|
||||
str.startsWith("/") ||
|
||||
str.includes("auth.") ||
|
||||
str.includes("$1auth.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific strings that should be excluded
|
||||
if (
|
||||
str === "Cache Hit:" ||
|
||||
str === "Cache Write:" ||
|
||||
str === "ADD_DOCS" ||
|
||||
str === "ADD_DOCKERFILE" ||
|
||||
str === "Verified" ||
|
||||
str === "Others" ||
|
||||
str === "Feedback" ||
|
||||
str === "JSON File" ||
|
||||
str === "mt-0.5 md:mt-0"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for long suggestion texts
|
||||
if (
|
||||
str.length > 100 &&
|
||||
(str.includes("Please write a bash script") ||
|
||||
str.includes("Please investigate the repo") ||
|
||||
str.includes("Please push the changes") ||
|
||||
str.includes("Examine the dependencies") ||
|
||||
str.includes("Investigate the documentation") ||
|
||||
str.includes("Investigate the current repo") ||
|
||||
str.includes("I want to create a Hello World app") ||
|
||||
str.includes("I want to create a VueJS app") ||
|
||||
str.includes("This should be a client-only app"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific error messages and UI text
|
||||
if (
|
||||
str === "All data associated with this project will be lost." ||
|
||||
str === "You will lose any unsaved information." ||
|
||||
str ===
|
||||
"This conversation does not exist, or you do not have permission to access it." ||
|
||||
str === "Failed to fetch settings. Please try reloading." ||
|
||||
str ===
|
||||
"If you tell OpenHands to start a web server, the app will appear here." ||
|
||||
str ===
|
||||
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
|
||||
str ===
|
||||
"Something went wrong while fetching settings. Please reload the page." ||
|
||||
str ===
|
||||
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
|
||||
str === "Please push the latest changes to the existing pull request."
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against all technical patterns
|
||||
return technicalPatterns.some((pattern) => pattern.test(str));
|
||||
}
|
||||
|
||||
function isLikelyUserFacingText(str) {
|
||||
|
||||
// Basic validation - skip very short strings or strings without letters
|
||||
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a specifically excluded technical string
|
||||
if (isExcludedTechnicalString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like a code rather than a key
|
||||
if (isLikelyCode(str)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a raw translation key that should be wrapped in t()
|
||||
if (isRawTranslationKey(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
|
||||
// These should be wrapped in t() or use I18nKey enum
|
||||
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First, check if it's a common development string (not user-facing)
|
||||
if (isCommonDevelopmentString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi-word phrases are likely UI text
|
||||
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
|
||||
|
||||
// Sentences and questions are likely UI text
|
||||
const hasPunctuation = /[?!.,:]/.test(str);
|
||||
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
|
||||
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
|
||||
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
|
||||
const hasQuestionForm =
|
||||
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Product names and camelCase identifiers are likely UI text
|
||||
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
|
||||
|
||||
// Instruction text patterns are likely UI text
|
||||
const looksLikeInstruction =
|
||||
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Error and status messages are likely UI text
|
||||
const looksLikeErrorOrStatus =
|
||||
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Single word check - assume it's UI text unless proven otherwise
|
||||
const isSingleWord =
|
||||
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
|
||||
|
||||
// For single words, we need to be more careful
|
||||
if (isSingleWord) {
|
||||
// Skip common programming terms and variable names
|
||||
const isCommonProgrammingTerm =
|
||||
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonProgrammingTerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common variable name patterns
|
||||
const looksLikeVariableName =
|
||||
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
|
||||
|
||||
if (looksLikeVariableName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common CSS values
|
||||
const isCommonCssValue =
|
||||
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonCssValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common file extensions
|
||||
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
|
||||
if (isFileExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common abbreviations
|
||||
const isCommonAbbreviation =
|
||||
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonAbbreviation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
|
||||
// it might be UI text, but we'll be conservative and return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
|
||||
return (
|
||||
hasMultipleWords ||
|
||||
hasPunctuation ||
|
||||
isCapitalizedPhrase ||
|
||||
isTitleCase ||
|
||||
hasSentenceStructure ||
|
||||
hasQuestionForm ||
|
||||
hasInternalCapitals ||
|
||||
looksLikeInstruction ||
|
||||
looksLikeErrorOrStatus
|
||||
);
|
||||
}
|
||||
|
||||
function isInTranslationContext(path) {
|
||||
// Check if the JSX text is inside a <Trans> component
|
||||
let current = path;
|
||||
while (current.parentPath) {
|
||||
if (
|
||||
current.isJSXElement() &&
|
||||
current.node.openingElement &&
|
||||
current.node.openingElement.name &&
|
||||
current.node.openingElement.name.name === "Trans"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanFileForUnlocalizedStrings(filePath) {
|
||||
// Skip suggestion content files as they contain special strings that are already properly localized
|
||||
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const unlocalizedStrings = [];
|
||||
|
||||
// Skip files that are too large
|
||||
if (content.length > 1000000) {
|
||||
console.warn(`Skipping large file: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the file
|
||||
const ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
|
||||
});
|
||||
|
||||
// Traverse the AST
|
||||
traverse(ast, {
|
||||
// Find JSX text content
|
||||
JSXText(jsxTextPath) {
|
||||
const text = jsxTextPath.node.value.trim();
|
||||
if (
|
||||
text &&
|
||||
isLikelyUserFacingText(text) &&
|
||||
!isInTranslationContext(jsxTextPath)
|
||||
) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in JSX attributes
|
||||
JSXAttribute(jsxAttrPath) {
|
||||
const attrName = jsxAttrPath.node.name.name.toString();
|
||||
|
||||
// Skip technical attributes that don't contain user-facing text
|
||||
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip styling attributes
|
||||
if (
|
||||
attrName === "className" ||
|
||||
attrName === "class" ||
|
||||
attrName === "style"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip data attributes and event handlers
|
||||
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the attribute value
|
||||
const value = jsxAttrPath.node.value;
|
||||
if (value && value.type === "StringLiteral") {
|
||||
const text = value.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in code
|
||||
StringLiteral(stringPath) {
|
||||
// Skip if parent is JSX attribute (already handled above)
|
||||
if (stringPath.parent.type === "JSXAttribute") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is import/export declaration
|
||||
if (
|
||||
stringPath.parent.type === "ImportDeclaration" ||
|
||||
stringPath.parent.type === "ExportDeclaration"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is object property key
|
||||
if (
|
||||
stringPath.parent.type === "ObjectProperty" &&
|
||||
stringPath.parent.key === stringPath.node
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if inside a t() call or Trans component
|
||||
let isInsideTranslation = false;
|
||||
let current = stringPath;
|
||||
|
||||
while (current.parentPath && !isInsideTranslation) {
|
||||
// Check for t() function call
|
||||
if (
|
||||
current.parent.type === "CallExpression" &&
|
||||
current.parent.callee &&
|
||||
((current.parent.callee.type === "Identifier" &&
|
||||
current.parent.callee.name === "t") ||
|
||||
(current.parent.callee.type === "MemberExpression" &&
|
||||
current.parent.callee.property &&
|
||||
current.parent.callee.property.name === "t"))
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for <Trans> component
|
||||
if (
|
||||
current.parent.type === "JSXElement" &&
|
||||
current.parent.openingElement &&
|
||||
current.parent.openingElement.name &&
|
||||
current.parent.openingElement.name.name === "Trans"
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.parentPath;
|
||||
}
|
||||
|
||||
if (!isInsideTranslation) {
|
||||
const text = stringPath.node.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return unlocalizedStrings;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function scanDirectoryForUnlocalizedStrings(dirPath) {
|
||||
const results = new Map();
|
||||
|
||||
function scanDir(currentPath) {
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (!shouldIgnorePath(fullPath)) {
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SCAN_EXTENSIONS.includes(path.extname(fullPath))
|
||||
) {
|
||||
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
|
||||
if (unlocalized.length > 0) {
|
||||
results.set(fullPath, unlocalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(dirPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run the check
|
||||
try {
|
||||
const srcPath = path.resolve(__dirname, '../src');
|
||||
console.log('Checking for unlocalized strings in frontend code...');
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
// If we found any unlocalized strings, format them for output and exit with error
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ No unlocalized strings found in frontend code.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error running unlocalized strings check:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -258,19 +258,17 @@ class OpenHands {
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
suggested_task?: SuggestedTask,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
replay_json: replayJson,
|
||||
suggested_task,
|
||||
conversation_instructions: conversationInstructions,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
@@ -479,6 +477,18 @@ 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;
|
||||
|
||||
@@ -50,9 +50,11 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +86,7 @@ export interface Conversation {
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: string | null;
|
||||
git_provider: Provider | null;
|
||||
last_updated_at: string;
|
||||
created_at: string;
|
||||
status: ConversationStatus;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -12,12 +13,17 @@ import { paragraph } from "../markdown/paragraph";
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
message: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
type,
|
||||
message,
|
||||
children,
|
||||
actions,
|
||||
}: React.PropsWithChildren<ChatMessageProps>) {
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [isCopy, setIsCopy] = React.useState(false);
|
||||
@@ -47,18 +53,39 @@ export function ChatMessage({
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={cn(
|
||||
"rounded-xl relative",
|
||||
"rounded-xl relative w-fit",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2.5 -right-2.5",
|
||||
!isHovering ? "hidden" : "flex",
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words">
|
||||
<Markdown
|
||||
components={{
|
||||
@@ -68,7 +95,7 @@ export function ChatMessage({
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -46,7 +47,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) {
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{defaultMessage}
|
||||
</Markdown>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
|
||||
import { LikertScale } from "../feedback/likert-scale";
|
||||
@@ -35,6 +37,13 @@ interface EventMessageProps {
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -43,6 +52,10 @@ export function EventMessage({
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
@@ -82,27 +95,66 @@ export function EventMessage({
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<ErrorMessage
|
||||
errorId={event.extras.error_id}
|
||||
defaultMessage={event.message}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasObservationPair && isOpenHandsAction(event)) {
|
||||
if (hasThoughtProperty(event.args)) {
|
||||
return <ChatMessage type="agent" message={event.args.thought} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={event.args.thought}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return microagentStatus && actions ? (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isFinishAction(event)) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
);
|
||||
@@ -113,7 +165,7 @@ export function EventMessage({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
<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} />
|
||||
)}
|
||||
@@ -122,6 +174,13 @@ export function EventMessage({
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{isAssistantMessage(event) &&
|
||||
event.action === "message" &&
|
||||
renderLikertScale()}
|
||||
@@ -130,7 +189,11 @@ export function EventMessage({
|
||||
}
|
||||
|
||||
if (isRejectObservation(event)) {
|
||||
return <ChatMessage type="agent" message={event.content} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={event.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMcpObservation(event)) {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
@@ -199,7 +200,7 @@ export function ExpandableMessage({
|
||||
ol,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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";
|
||||
@@ -52,7 +53,7 @@ export function GenericEventMessage({
|
||||
ul,
|
||||
ol,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
>
|
||||
{details}
|
||||
</Markdown>
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
|
||||
import {
|
||||
isOpenHandsAction,
|
||||
isOpenHandsObservation,
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isFinishAction,
|
||||
} from "#/types/core/guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
|
||||
import {
|
||||
MicroagentStatus,
|
||||
EventMicroagentStatus,
|
||||
} from "#/types/microagent-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
@@ -13,10 +31,23 @@ interface MessagesProps {
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
|
||||
React.useState(false);
|
||||
const [microagentStatuses, setMicroagentStatuses] = React.useState<
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -30,6 +61,139 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
[messages],
|
||||
);
|
||||
|
||||
const getMicroagentStatusForEvent = React.useCallback(
|
||||
(eventId: number): MicroagentStatus | null => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.status || null;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentConversationIdForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.conversationId || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentPRUrlForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.prUrl || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const handleMicroagentEvent = React.useCallback(
|
||||
(socketEvent: unknown, microagentConversationId: string) => {
|
||||
// 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;
|
||||
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.ERROR }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent)
|
||||
) {
|
||||
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isFinishAction(socketEvent)
|
||||
) {
|
||||
// Check if the finish action contains a PR URL
|
||||
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
|
||||
if (prUrl) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? {
|
||||
...statusEntry,
|
||||
status: MicroagentStatus.COMPLETED,
|
||||
prUrl,
|
||||
}
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setMicroagentStatuses],
|
||||
);
|
||||
|
||||
const handleLaunchMicroagent = (
|
||||
query: string,
|
||||
target: string,
|
||||
triggers: string[],
|
||||
) => {
|
||||
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
|
||||
if (
|
||||
!conversation ||
|
||||
!conversation.selected_repository ||
|
||||
!conversation.selected_branch ||
|
||||
!conversation.git_provider ||
|
||||
!selectedEventId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
createConversationAndSubscribe({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: conversation.selected_repository,
|
||||
branch: conversation.selected_branch,
|
||||
gitProvider: conversation.git_provider,
|
||||
},
|
||||
onSuccessCallback: (newConversationId: string) => {
|
||||
setShowLaunchMicroagentModal(false);
|
||||
// Update status with conversation ID
|
||||
setMicroagentStatuses((prev) => [
|
||||
...prev.filter((status) => status.eventId !== selectedEventId),
|
||||
{
|
||||
eventId: selectedEventId,
|
||||
conversationId: newConversationId,
|
||||
status: MicroagentStatus.CREATING,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
|
||||
handleMicroagentEvent(socketEvent, newConversationId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => (
|
||||
@@ -39,6 +203,26 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
microagentStatus={getMicroagentStatusForEvent(message.id)}
|
||||
microagentConversationId={getMicroagentConversationIdForEvent(
|
||||
message.id,
|
||||
)}
|
||||
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
|
||||
actions={
|
||||
conversation?.selected_repository
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MemoryIcon className="w-[14px] h-[14px] text-white" />
|
||||
),
|
||||
onClick: () => {
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
/>
|
||||
))}
|
||||
@@ -46,6 +230,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
{conversation?.selected_repository &&
|
||||
showLaunchMicroagentModal &&
|
||||
selectedEventId &&
|
||||
createPortal(
|
||||
<LaunchMicroagentModal
|
||||
onClose={() => setShowLaunchMicroagentModal(false)}
|
||||
onLaunch={handleLaunchMicroagent}
|
||||
selectedRepo={
|
||||
conversation.selected_repository.split("/").pop() || ""
|
||||
}
|
||||
eventId={selectedEventId}
|
||||
isLoading={isPending}
|
||||
/>,
|
||||
document.getElementById("modal-portal-exit") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../../settings/brand-button";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
|
||||
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
|
||||
import { LoadingMicroagentBody } from "./loading-microagent-body";
|
||||
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
|
||||
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
|
||||
|
||||
interface LaunchMicroagentModalProps {
|
||||
onClose: () => void;
|
||||
onLaunch: (query: string, target: string, triggers: string[]) => void;
|
||||
eventId: number;
|
||||
isLoading: boolean;
|
||||
selectedRepo: string;
|
||||
}
|
||||
|
||||
export function LaunchMicroagentModal({
|
||||
onClose,
|
||||
onLaunch,
|
||||
eventId,
|
||||
isLoading,
|
||||
selectedRepo,
|
||||
}: LaunchMicroagentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { runtimeActive } = useHandleRuntimeActive();
|
||||
const { data: prompt, isLoading: promptIsLoading } =
|
||||
useMicroagentPrompt(eventId);
|
||||
|
||||
const { data: microagents, isLoading: microagentsIsLoading } =
|
||||
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
|
||||
|
||||
const [triggers, setTriggers] = React.useState<string[]>([]);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const query = formData.get("query-input")?.toString();
|
||||
const target = formData.get("target-input")?.toString();
|
||||
|
||||
if (query && target) {
|
||||
onLaunch(query, target, triggers);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
{!runtimeActive && <LoadingMicroagentBody />}
|
||||
{runtimeActive && (
|
||||
<ModalBody className="items-start w-[728px]">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
{t("MICROAGENT$WHAT_TO_REMEMBER")}
|
||||
{promptIsLoading && <LoadingMicroagentTextarea />}
|
||||
{!promptIsLoading && (
|
||||
<textarea
|
||||
required
|
||||
data-testid="query-input"
|
||||
name="query-input"
|
||||
defaultValue={prompt}
|
||||
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="target-input"
|
||||
name="target-input"
|
||||
label={t("MICROAGENT$WHERE_TO_PUT")}
|
||||
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
|
||||
required
|
||||
allowsCustomValue
|
||||
isLoading={microagentsIsLoading}
|
||||
items={
|
||||
microagents?.map((item) => ({
|
||||
key: item,
|
||||
label: item,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="trigger-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t("MICROAGENT$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}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<BrandButton type="button" variant="secondary" onClick={onClose}>
|
||||
{t("MICROAGENT$CANCEL")}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={
|
||||
isLoading || promptIsLoading || microagentsIsLoading
|
||||
}
|
||||
>
|
||||
{t("MICROAGENT$LAUNCH")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
export function LoadingMicroagentBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody>
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
</h2>
|
||||
<Spinner size="lg" />
|
||||
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function LoadingMicroagentTextarea() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
required
|
||||
disabled
|
||||
defaultValue=""
|
||||
placeholder={t("MICROAGENT$LOADING_PROMPT")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface MicroagentStatusIndicatorProps {
|
||||
status: MicroagentStatus;
|
||||
conversationId?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export function MicroagentStatusIndicator({
|
||||
status,
|
||||
conversationId,
|
||||
prUrl,
|
||||
}: MicroagentStatusIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
// If there's a PR URL, show "View your PR" instead of the default completed message
|
||||
return prUrl
|
||||
? t("MICROAGENT$VIEW_YOUR_PR")
|
||||
: t("MICROAGENT$STATUS_COMPLETED");
|
||||
case MicroagentStatus.ERROR:
|
||||
return t("MICROAGENT$STATUS_ERROR");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
return <SuccessIndicator status="success" />;
|
||||
case MicroagentStatus.ERROR:
|
||||
return <SuccessIndicator status="error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = getStatusText();
|
||||
const shouldShowAsLink = !!conversationId;
|
||||
const shouldShowPRLink = !!prUrl;
|
||||
|
||||
const renderStatusText = () => {
|
||||
if (shouldShowPRLink) {
|
||||
return (
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowAsLink) {
|
||||
return (
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="underline">{statusText}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
|
||||
{getStatusIcon()}
|
||||
{renderStatusText()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface ConversationCreatedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationCreatedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$ADDING_CONTEXT")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationFinishedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationFinishedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="success" />
|
||||
<div>
|
||||
{t("MICROAGENT$SUCCESS_PR_READY")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationErroredToastProps {
|
||||
errorMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const renderConversationCreatedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationCreatedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationFinishedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationFinishedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationErroredToast = (
|
||||
conversationId: string,
|
||||
errorMessage: string,
|
||||
) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationErroredToast
|
||||
errorMessage={errorMessage}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
@@ -17,8 +17,12 @@ export function BudgetUsageText({
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
|
||||
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
|
||||
{t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, {
|
||||
currentCost: `$${currentCost.toFixed(4)}`,
|
||||
maxBudget: `$${maxBudget.toFixed(4)}`,
|
||||
usagePercentage: usagePercentage.toFixed(2),
|
||||
used: t(I18nKey.CONVERSATION$USED),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -330,11 +330,15 @@ export function ConversationCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">Cache Hit:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">Cache Write:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
@@ -409,10 +413,7 @@ export function ConversationCard({
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal
|
||||
onClose={() => setMicroagentsModalVisible(false)}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
@@ -39,6 +41,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
const { mutate: updateConversation } = useUpdateConversation();
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
@@ -50,6 +53,20 @@ 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(
|
||||
@@ -114,6 +131,9 @@ 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,
|
||||
|
||||
@@ -13,13 +13,9 @@ import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
conversationId: string | undefined;
|
||||
}
|
||||
|
||||
export function MicroagentsModal({
|
||||
onClose,
|
||||
conversationId,
|
||||
}: MicroagentsModalProps) {
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
@@ -31,11 +27,7 @@ export function MicroagentsModal({
|
||||
isError,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationMicroagents({
|
||||
agentState: curAgentState,
|
||||
conversationId,
|
||||
enabled: true,
|
||||
});
|
||||
} = useConversationMicroagents();
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -207,7 +208,7 @@ export function LikertScale({
|
||||
className={cn("text-xl transition-all", getButtonClass(rating))}
|
||||
aria-label={`Rate ${rating} stars`}
|
||||
>
|
||||
★
|
||||
<FaStar />
|
||||
</button>
|
||||
))}
|
||||
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
|
||||
export function HomeHeader() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -28,7 +30,15 @@ export function HomeHeader() {
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
{},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && t("HOME$LAUNCH_FROM_SCRATCH")}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { RepoProviderLinks } from "./repo-provider-links";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
interface RepoConnectorProps {
|
||||
onRepoSelection: (repoTitle: string | null) => void;
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
}
|
||||
|
||||
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseRepositoryBranches = vi.fn();
|
||||
const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseRepositoryBranches.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversation.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
providersAreSet: true,
|
||||
user: {
|
||||
id: 1,
|
||||
login: "testuser",
|
||||
avatar_url: "https://example.com/avatar.png",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
company: "Test Company",
|
||||
},
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock the modules
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-repository-branches", () => ({
|
||||
useRepositoryBranches: () => mockUseRepositoryBranches(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => mockUseCreateConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
const renderRepositorySelectionForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows loading indicator when repositories are being fetched", () => {
|
||||
// Setup loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows dropdown when repositories are loaded", () => {
|
||||
// Setup loaded repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if dropdown is displayed
|
||||
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error message when repository fetch fails", () => {
|
||||
// Setup error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Failed to fetch repositories"),
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if error message is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@@ -19,12 +20,13 @@ import {
|
||||
} from "./repository-selection";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repoTitle: string | null) => void;
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
}
|
||||
|
||||
export function RepositorySelectionForm({
|
||||
onRepoSelection,
|
||||
}: RepositorySelectionFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
React.useState<GitRepository | null>(null);
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
@@ -94,8 +96,7 @@ export function RepositorySelectionForm({
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
|
||||
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
setSelectedBranch(null); // Reset branch selection when repo changes
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
@@ -209,10 +210,19 @@ export function RepositorySelectionForm({
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation({
|
||||
selectedRepository,
|
||||
selected_branch: selectedBranch?.name,
|
||||
})
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
@@ -23,29 +22,28 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { data: repositories } = useUserRepositories();
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const selectedRepo = repositories?.find(
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
|
||||
|
||||
return createConversation({
|
||||
selectedRepository: repo,
|
||||
suggested_task: task,
|
||||
});
|
||||
return createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: task.repo,
|
||||
gitProvider: task.git_provider,
|
||||
},
|
||||
suggestedTask: task,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
navigate(`/conversations/${data.conversation_id}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Determine the correct URL format based on git provider
|
||||
@@ -64,7 +62,7 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6 last:border-b-0">
|
||||
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
|
||||
|
||||
<div className="w-full pl-8">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user