mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
58 Commits
ray/previe
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e754edb798 | ||
|
|
d6fab190bf | ||
|
|
833aae1833 | ||
|
|
2841e35f24 | ||
|
|
8115d82f96 | ||
|
|
7263657937 | ||
|
|
34fcc50350 | ||
|
|
24a9758434 | ||
|
|
f24d2a61e6 | ||
|
|
e3d0380c2e | ||
|
|
8c3f93ddc4 | ||
|
|
bc86796a67 | ||
|
|
d5b2d2ebc5 | ||
|
|
b605c96796 | ||
|
|
8192184d3e | ||
|
|
8e75f25108 | ||
|
|
73fe865c7e | ||
|
|
95a44f4248 | ||
|
|
0a6b76ca2d | ||
|
|
8b6521de62 | ||
|
|
11636edf15 | ||
|
|
915c180ba7 | ||
|
|
cdd8aace86 | ||
|
|
a2c312d108 | ||
|
|
5ad3572810 | ||
|
|
967e9e1891 | ||
|
|
f8a41d3ffe | ||
|
|
6e9e7547e5 | ||
|
|
9b4f1c365b | ||
|
|
f4dcc136d0 | ||
|
|
36a8cbbfe4 | ||
|
|
83a3c2c5bf | ||
|
|
63c9e6403f | ||
|
|
bff734070c | ||
|
|
5db6bffaf6 | ||
|
|
14807ed273 | ||
|
|
e0d26c1f4e | ||
|
|
27c8c330f4 | ||
|
|
0c927b19d2 | ||
|
|
a660321d55 | ||
|
|
0e94833d5b | ||
|
|
b83e2877ec | ||
|
|
7acee16de5 | ||
|
|
1e3f1de773 | ||
|
|
bfe60d3bbf | ||
|
|
ad75cd05d8 | ||
|
|
955f87561b | ||
|
|
1e5bff82f2 | ||
|
|
ddf58da995 | ||
|
|
b678d548c2 | ||
|
|
a1d4d62f68 | ||
|
|
75e54e3552 | ||
|
|
6b211f3b29 | ||
|
|
e208b64a95 | ||
|
|
c4ff3d6483 | ||
|
|
bace2ef8a1 | ||
|
|
4e7846928b | ||
|
|
62dbb20846 |
1
.devcontainer/README.md
Normal file
1
.devcontainer/README.md
Normal file
@@ -0,0 +1 @@
|
||||
This way of running OpenHands is not officially supported. It is maintained by the community.
|
||||
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -13,6 +13,7 @@
|
||||
- [ ] Other (dependency update, docs, typo fixes, etc.)
|
||||
|
||||
## Checklist
|
||||
<!-- AI/LLM AGENTS: This checklist is for a human author to complete. Do NOT check either of the two boxes below. Leave them unchecked until a human has personally reviewed and tested the changes. -->
|
||||
|
||||
- [ ] I have read and reviewed the code and I understand what the code is doing.
|
||||
- [ ] I have tested the code to the best of my ability and ensured it works as expected.
|
||||
|
||||
65
.github/workflows/check-package-versions.yml
vendored
Normal file
65
.github/workflows/check-package-versions.yml
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Check Package Versions
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
check-package-versions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check for any 'rev' fields in pyproject.toml
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import sys, tomllib, pathlib
|
||||
|
||||
path = pathlib.Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
print("❌ ERROR: pyproject.toml not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
print(f"❌ ERROR: Failed to parse pyproject.toml: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
poetry = data.get("tool", {}).get("poetry", {})
|
||||
sections = {
|
||||
"dependencies": poetry.get("dependencies", {}),
|
||||
}
|
||||
|
||||
errors = []
|
||||
|
||||
print("🔍 Checking for any dependencies with 'rev' fields...\n")
|
||||
for section_name, deps in sections.items():
|
||||
if not isinstance(deps, dict):
|
||||
continue
|
||||
|
||||
for pkg_name, cfg in deps.items():
|
||||
if isinstance(cfg, dict) and "rev" in cfg:
|
||||
msg = f" ✖ {pkg_name} in [{section_name}] uses rev='{cfg['rev']}' (NOT ALLOWED)"
|
||||
print(msg)
|
||||
errors.append(msg)
|
||||
else:
|
||||
print(f" • {pkg_name}: OK")
|
||||
|
||||
if errors:
|
||||
print("\n❌ FAILED: Found dependencies using 'rev' fields:\n" + "\n".join(errors))
|
||||
print("\nPlease use versioned releases instead, e.g.:")
|
||||
print(' my-package = "1.0.0"')
|
||||
sys.exit(1)
|
||||
|
||||
print("\n✅ SUCCESS: No 'rev' fields found. All dependencies are using proper versioned releases.")
|
||||
PY
|
||||
22
.github/workflows/enterprise-preview.yml
vendored
22
.github/workflows/enterprise-preview.yml
vendored
@@ -19,31 +19,11 @@ jobs:
|
||||
if: github.event.label.name == 'deploy'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v3
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-author: 'github-actions[bot]'
|
||||
body-includes: |
|
||||
⚠️ Enterprise preview
|
||||
|
||||
- name: Comment warning on PR
|
||||
uses: peter-evans/create-or-update-comment@v4
|
||||
with:
|
||||
issue-number: ${{ github.event.pull_request.number }}
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
⚠️ Enterprise preview: you can check the build for progress and errors here:
|
||||
|
||||
https://github.com/OpenHands/deploy/actions/workflows/deploy.yaml
|
||||
|
||||
# This should match the version in ghcr-build.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.OPENHANDS_AGENT_PAT }}" \
|
||||
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/OpenHands/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
|
||||
199
.github/workflows/integration-runner.yml
vendored
199
.github/workflows/integration-runner.yml
vendored
@@ -1,199 +0,0 @@
|
||||
name: Run Integration Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: true
|
||||
default: ''
|
||||
schedule:
|
||||
- cron: '30 22 * * *' # Runs at 10:30pm UTC every day
|
||||
|
||||
env:
|
||||
N_PROCESSES: 10 # Global configuration for number of parallel processes for evaluation
|
||||
|
||||
jobs:
|
||||
run-integration-tests:
|
||||
if: github.event.label.name == 'integration-test' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
permissions:
|
||||
contents: "read"
|
||||
id-token: "write"
|
||||
pull-requests: "write"
|
||||
issues: "write"
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22.x'
|
||||
|
||||
- name: Comment on PR if 'integration-test' label is present
|
||||
if: github.event_name == 'pull_request' && github.event.label.name == 'integration-test'
|
||||
uses: KeisukeYamashita/create-comment@v1
|
||||
with:
|
||||
unique: false
|
||||
comment: |
|
||||
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
|
||||
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --with dev,test,runtime,evaluation
|
||||
|
||||
- name: Configure config.toml for testing with Haiku
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 10
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
|
||||
- name: Build environment
|
||||
run: make build
|
||||
|
||||
- name: Run integration test evaluation for Haiku
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'haiku_run'
|
||||
|
||||
# get integration tests report
|
||||
REPORT_FILE_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/*haiku*_maxiter_10_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE: $REPORT_FILE_HAIKU"
|
||||
echo "INTEGRATION_TEST_REPORT_HAIKU<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_HAIKU >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Wait a little bit
|
||||
run: sleep 10
|
||||
|
||||
- name: Configure config.toml for testing with DeepSeek
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/deepseek-chat"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 10
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
|
||||
- name: Run integration test evaluation for DeepSeek
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD CodeActAgent '' 10 $N_PROCESSES '' 'deepseek_run'
|
||||
|
||||
# get integration tests report
|
||||
REPORT_FILE_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/CodeActAgent/deepseek*_maxiter_10_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE: $REPORT_FILE_DEEPSEEK"
|
||||
echo "INTEGRATION_TEST_REPORT_DEEPSEEK<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_DEEPSEEK >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
|
||||
- name: Wait a little bit (again)
|
||||
run: sleep 5
|
||||
|
||||
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
|
||||
env:
|
||||
LLM_MODEL: "litellm_proxy/deepseek-chat"
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
MAX_ITERATIONS: 15
|
||||
run: |
|
||||
echo "[llm.eval]" > config.toml
|
||||
echo "model = \"$LLM_MODEL\"" >> config.toml
|
||||
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
|
||||
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
|
||||
echo "temperature = 0.0" >> config.toml
|
||||
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
|
||||
env:
|
||||
SANDBOX_FORCE_REBUILD_RUNTIME: True
|
||||
run: |
|
||||
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
|
||||
|
||||
# Find and export the visual browsing agent test results
|
||||
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
|
||||
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
|
||||
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
|
||||
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
|
||||
echo >> $GITHUB_ENV
|
||||
echo "EOF" >> $GITHUB_ENV
|
||||
|
||||
- name: Create archive of evaluation outputs
|
||||
run: |
|
||||
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
|
||||
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
|
||||
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
|
||||
|
||||
- name: Upload evaluation results as artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
id: upload_results_artifact
|
||||
with:
|
||||
name: integration-test-outputs-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: integration_tests_*.tar.gz
|
||||
|
||||
- name: Get artifact URLs
|
||||
run: |
|
||||
echo "ARTIFACT_URL=${{ steps.upload_results_artifact.outputs.artifact-url }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set timestamp and trigger reason
|
||||
run: |
|
||||
echo "TIMESTAMP=$(date +'%Y-%m-%d-%H-%M')" >> $GITHUB_ENV
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
echo "TRIGGER_REASON=pr-${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "TRIGGER_REASON=manual-${{ github.event.inputs.reason }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "TRIGGER_REASON=nightly-scheduled" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Comment with results and artifact link
|
||||
id: create_comment
|
||||
uses: KeisukeYamashita/create-comment@v1
|
||||
with:
|
||||
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
|
||||
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
|
||||
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' }}
|
||||
Commit: ${{ github.sha }}
|
||||
**Integration Tests Report (Haiku)**
|
||||
Haiku LLM Test Results:
|
||||
${{ env.INTEGRATION_TEST_REPORT_HAIKU }}
|
||||
---
|
||||
**Integration Tests Report (DeepSeek)**
|
||||
DeepSeek LLM Test Results:
|
||||
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
|
||||
---
|
||||
**Integration Tests Report VisualBrowsing (DeepSeek)**
|
||||
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
|
||||
---
|
||||
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})
|
||||
32
.github/workflows/py-tests.yml
vendored
32
.github/workflows/py-tests.yml
vendored
@@ -70,37 +70,7 @@ jobs:
|
||||
.coverage.${{ matrix.python_version }}
|
||||
.coverage.runtime.${{ matrix.python_version }}
|
||||
include-hidden-files: true
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
name: Python Tests on Windows
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install pipx
|
||||
run: pip install pipx
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --with dev,test,runtime
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/runtime//test_windows_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
DEBUG: "1"
|
||||
- name: Run Windows runtime tests with LocalRuntime
|
||||
run: $env:TEST_RUNTIME="local"; poetry run pytest -svv tests/runtime/test_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
TEST_RUNTIME: local
|
||||
DEBUG: "1"
|
||||
|
||||
test-enterprise:
|
||||
name: Enterprise Python Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -185,6 +185,9 @@ cython_debug/
|
||||
.repomix
|
||||
repomix-output.txt
|
||||
|
||||
# Emacs backup
|
||||
*~
|
||||
|
||||
# evaluation
|
||||
evaluation/evaluation_outputs
|
||||
evaluation/outputs
|
||||
|
||||
@@ -58,7 +58,7 @@ by implementing the [interface specified here](https://github.com/OpenHands/Open
|
||||
|
||||
#### Testing
|
||||
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing test suites.
|
||||
At the moment, we have two kinds of tests: [`unit`](./tests/unit) and [`integration`](./evaluation/integration_tests). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
|
||||
At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e). Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure quality of the project.
|
||||
|
||||
## Sending Pull Requests to OpenHands
|
||||
|
||||
|
||||
@@ -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/openhands/runtime:0.61-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.62-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
|
||||
|
||||
## ☁️ OpenHands Cloud
|
||||
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
|
||||
which comes with $20 in free credits for new users.
|
||||
which comes with $10 in free credits for new users.
|
||||
|
||||
## 💻 Running OpenHands Locally
|
||||
|
||||
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.62-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.62-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.openhands.dev/openhands/openhands:0.61
|
||||
docker.openhands.dev/openhands/openhands:0.62
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Develop in Docker
|
||||
|
||||
> [!WARNING]
|
||||
> This is not officially supported and may not work.
|
||||
> This way of running OpenHands is not officially supported. It is maintained by the community and may not work.
|
||||
|
||||
Install [Docker](https://docs.docker.com/engine/install/) on your host machine and run:
|
||||
|
||||
|
||||
@@ -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/openhands/runtime:0.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.62-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.openhands.dev/openhands/runtime:0.61-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.62-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:
|
||||
|
||||
@@ -5,12 +5,8 @@ from experiments.constants import (
|
||||
EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT,
|
||||
)
|
||||
from experiments.experiment_versions import (
|
||||
handle_condenser_max_step_experiment,
|
||||
handle_system_prompt_experiment,
|
||||
)
|
||||
from experiments.experiment_versions._004_condenser_max_step_experiment import (
|
||||
handle_condenser_max_step_experiment__v1,
|
||||
)
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -31,10 +27,6 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
)
|
||||
return agent
|
||||
|
||||
agent = handle_condenser_max_step_experiment__v1(
|
||||
user_id, conversation_id, agent
|
||||
)
|
||||
|
||||
if EXPERIMENT_SYSTEM_PROMPT_EXPERIMENT:
|
||||
agent = agent.model_copy(
|
||||
update={'system_prompt_filename': 'system_prompt_long_horizon.j2'}
|
||||
@@ -60,20 +52,7 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
"""
|
||||
logger.debug(
|
||||
'experiment_manager:run_conversation_variant_test:started',
|
||||
extra={'user_id': user_id},
|
||||
)
|
||||
|
||||
# Skip all experiment processing if the experiment manager is disabled
|
||||
if not ENABLE_EXPERIMENT_MANAGER:
|
||||
logger.info(
|
||||
'experiment_manager:run_conversation_variant_test:skipped',
|
||||
extra={'reason': 'experiment_manager_disabled'},
|
||||
)
|
||||
return conversation_settings
|
||||
|
||||
# Apply conversation-scoped experiments
|
||||
conversation_settings = handle_condenser_max_step_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
extra={'user_id': user_id, 'conversation_id': conversation_id},
|
||||
)
|
||||
|
||||
return conversation_settings
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""add status and updated_at to callback
|
||||
|
||||
Revision ID: 080
|
||||
Revises: 079
|
||||
Create Date: 2025-11-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '080'
|
||||
down_revision: Union[str, None] = '079'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
class EventCallbackStatus(Enum):
|
||||
ACTIVE = 'ACTIVE'
|
||||
DISABLED = 'DISABLED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
status = sa.Enum(EventCallbackStatus, name='eventcallbackstatus')
|
||||
status.create(op.get_bind(), checkfirst=True)
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column('status', status, nullable=False, server_default='ACTIVE'),
|
||||
)
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('event_callback', 'status')
|
||||
op.drop_column('event_callback', 'updated_at')
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.execute('DROP TYPE eventcallbackstatus')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add parent_conversation_id to conversation_metadata
|
||||
|
||||
Revision ID: 081
|
||||
Revises: 080
|
||||
Create Date: 2025-11-06 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '081'
|
||||
down_revision: Union[str, None] = '080'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('parent_conversation_id', sa.String(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_conversation_metadata_parent_conversation_id'),
|
||||
'conversation_metadata',
|
||||
['parent_conversation_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_index(
|
||||
op.f('ix_conversation_metadata_parent_conversation_id'),
|
||||
table_name='conversation_metadata',
|
||||
)
|
||||
op.drop_column('conversation_metadata', 'parent_conversation_id')
|
||||
53
enterprise/poetry.lock
generated
53
enterprise/poetry.lock
generated
@@ -5820,13 +5820,15 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a5"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.1.0-py3-none-any.whl", hash = "sha256:59a856883df23488c0723e47655ef21649a321fcd4709a25a4690866eff6ac88"},
|
||||
{file = "openhands_agent_server-1.1.0.tar.gz", hash = "sha256:e39bebd39afd45cfcfd765005e7c4e5409e46678bd7612ae20bae79f7057b935"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiosqlite = ">=0.19"
|
||||
@@ -5839,16 +5841,9 @@ uvicorn = ">=0.31.1"
|
||||
websockets = ">=12"
|
||||
wsproto = ">=1.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.0.0-post.5514+7c9e66194"
|
||||
version = "0.0.0-post.5525+0b6631523"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5889,9 +5884,9 @@ memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
|
||||
openhands-agent-server = "1.1.0"
|
||||
openhands-sdk = "1.1.0"
|
||||
openhands-tools = "1.1.0"
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5947,13 +5942,15 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a5"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_sdk-1.1.0-py3-none-any.whl", hash = "sha256:4a984ce1687a48cf99a67fdf3d37b116f8b2840743d4807810b5024af6a1d57e"},
|
||||
{file = "openhands_sdk-1.1.0.tar.gz", hash = "sha256:855e0d8f3657205e4119e50520c17e65b3358b1a923f7a051a82512a54bf426c"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
fastmcp = ">=2.11.3"
|
||||
@@ -5969,22 +5966,17 @@ websockets = ">=12"
|
||||
[package.extras]
|
||||
boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a5"
|
||||
version = "1.1.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = []
|
||||
develop = false
|
||||
files = [
|
||||
{file = "openhands_tools-1.1.0-py3-none-any.whl", hash = "sha256:767d6746f05edade49263aa24450a037485a3dc23379f56917ef19aad22033f9"},
|
||||
{file = "openhands_tools-1.1.0.tar.gz", hash = "sha256:c2fadaa4f4e16e9a3df5781ea847565dcae7171584f09ef7c0e1d97c8dfc83f6"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
bashlex = ">=0.18"
|
||||
@@ -5996,13 +5988,6 @@ libtmux = ">=0.46.2"
|
||||
openhands-sdk = "*"
|
||||
pydantic = ">=2.11.7"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
|
||||
@@ -30,6 +30,7 @@ from openhands.server.services.conversation_service import create_provider_token
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import get_access_token
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
from openhands.utils.posthog_tracker import track_user_signup_completed
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@@ -362,6 +363,12 @@ async def accept_tos(request: Request):
|
||||
|
||||
logger.info(f'User {user_id} accepted TOS')
|
||||
|
||||
# Track user signup completion in PostHog
|
||||
track_user_signup_completed(
|
||||
user_id=user_id,
|
||||
signup_timestamp=user_settings.accepted_tos.isoformat(),
|
||||
)
|
||||
|
||||
response = JSONResponse(
|
||||
status_code=status.HTTP_200_OK, content={'redirect_url': redirect_url}
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@ from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
from openhands.utils.posthog_tracker import track_credits_purchased
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing')
|
||||
@@ -457,6 +458,20 @@ async def success_callback(session_id: str, request: Request):
|
||||
)
|
||||
session.commit()
|
||||
|
||||
# Track credits purchased in PostHog
|
||||
try:
|
||||
track_credits_purchased(
|
||||
user_id=billing_session.user_id,
|
||||
amount_usd=amount_subtotal / 100, # Convert cents to dollars
|
||||
credits_added=add_credits,
|
||||
stripe_session_id=session_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to track credits purchase: {e}',
|
||||
extra={'user_id': billing_session.user_id, 'error': str(e)},
|
||||
)
|
||||
|
||||
return RedirectResponse(
|
||||
f'{request.base_url}settings/billing?checkout=success', status_code=302
|
||||
)
|
||||
|
||||
@@ -60,6 +60,7 @@ class SaasConversationStore(ConversationStore):
|
||||
kwargs.pop('reasoning_tokens', None)
|
||||
kwargs.pop('context_window', None)
|
||||
kwargs.pop('per_turn_token', None)
|
||||
kwargs.pop('parent_conversation_id', None)
|
||||
|
||||
return ConversationMetadata(**kwargs)
|
||||
|
||||
|
||||
@@ -92,11 +92,8 @@ def test_unknown_variant_returns_original_agent_without_changes(monkeypatch):
|
||||
assert getattr(result, 'condenser', None) is None
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.handle_condenser_max_step_experiment__v1')
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', False)
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
mock_handle_condenser,
|
||||
):
|
||||
def test_run_agent_variant_tests_v1_noop_when_manager_disabled():
|
||||
"""If ENABLE_EXPERIMENT_MANAGER is False, the method returns the exact same agent and does not call the handler."""
|
||||
agent = make_agent()
|
||||
conv_id = uuid4()
|
||||
@@ -109,8 +106,6 @@ def test_run_agent_variant_tests_v1_noop_when_manager_disabled(
|
||||
|
||||
# Same object returned (no copy)
|
||||
assert result is agent
|
||||
# Handler should not have been called
|
||||
mock_handle_condenser.assert_not_called()
|
||||
|
||||
|
||||
@patch('experiments.experiment_manager.ENABLE_EXPERIMENT_MANAGER', True)
|
||||
@@ -131,7 +126,3 @@ def test_run_agent_variant_tests_v1_calls_handler_and_sets_system_prompt(monkeyp
|
||||
# Should be a different instance than the original (copied after handler runs)
|
||||
assert result is not agent
|
||||
assert result.system_prompt_filename == 'system_prompt_long_horizon.j2'
|
||||
|
||||
# The condenser returned by the handler must be preserved after the system-prompt override copy
|
||||
assert isinstance(result.condenser, LLMSummarizingCondenser)
|
||||
assert result.condenser.max_size == 80
|
||||
|
||||
@@ -15,7 +15,7 @@ python evaluation/benchmarks/multi_swe_bench/scripts/data/data_change.py
|
||||
|
||||
## Docker image download
|
||||
|
||||
Please download the multi-swe-bench dokcer images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
|
||||
Please download the multi-swe-bench docker images from [here](https://github.com/multi-swe-bench/multi-swe-bench?tab=readme-ov-file#run-evaluation).
|
||||
|
||||
## Generate patch
|
||||
|
||||
@@ -47,7 +47,7 @@ For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=t
|
||||
|
||||
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
|
||||
## Running evaluation
|
||||
|
||||
First, install [multi-swe-bench](https://github.com/multi-swe-bench/multi-swe-bench).
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Integration tests
|
||||
|
||||
This directory implements integration tests that [was running in CI](https://github.com/OpenHands/OpenHands/tree/23d3becf1d6f5d07e592f7345750c314a826b4e9/tests/integration).
|
||||
|
||||
[PR 3985](https://github.com/OpenHands/OpenHands/pull/3985) introduce LLM-based editing, which requires access to LLM to perform edit. Hence, we remove integration tests from CI and intend to run them as nightly evaluation to ensure the quality of OpenHands softwares.
|
||||
|
||||
## To add new tests
|
||||
|
||||
Each test is a file named like `tXX_testname.py` where `XX` is a number.
|
||||
Make sure to name the file for each test to start with `t` and ends with `.py`.
|
||||
|
||||
Each test should be structured as a subclass of [`BaseIntegrationTest`](./tests/base.py), where you need to implement `initialize_runtime` that setup the runtime enviornment before test, and `verify_result` that takes in a `Runtime` and history of `Event` and return a `TestResult`. See [t01_fix_simple_typo.py](./tests/t01_fix_simple_typo.py) and [t05_simple_browsing.py](./tests/t05_simple_browsing.py) for two representative examples.
|
||||
|
||||
```python
|
||||
class TestResult(BaseModel):
|
||||
success: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class BaseIntegrationTest(ABC):
|
||||
"""Base class for integration tests."""
|
||||
|
||||
INSTRUCTION: str
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
"""Initialize the runtime for the test to run."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
"""Verify the result of the test.
|
||||
|
||||
This method will be called after the agent performs the task on the runtime.
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
## Setup Environment and LLM Configuration
|
||||
|
||||
Please follow instruction [here](../README.md#setup) to setup your local
|
||||
development environment and LLM.
|
||||
|
||||
## Start the evaluation
|
||||
|
||||
```bash
|
||||
./evaluation/integration_tests/scripts/run_infer.sh [model_config] [git-version] [agent] [eval_limit] [eval-num-workers] [eval_ids]
|
||||
```
|
||||
|
||||
- `model_config`, e.g. `eval_gpt4_1106_preview`, is the config group name for
|
||||
your LLM settings, as defined in your `config.toml`.
|
||||
- `git-version`, e.g. `HEAD`, is the git commit hash of the OpenHands version
|
||||
you would like to evaluate. It could also be a release tag like `0.9.0`.
|
||||
- `agent`, e.g. `CodeActAgent`, is the name of the agent for benchmarks,
|
||||
defaulting to `CodeActAgent`.
|
||||
- `eval_limit`, e.g. `10`, limits the evaluation to the first `eval_limit`
|
||||
instances. By default, the script evaluates the entire Exercism test set
|
||||
(133 issues). Note: in order to use `eval_limit`, you must also set `agent`.
|
||||
- `eval-num-workers`: the number of workers to use for evaluation. Default: `1`.
|
||||
- `eval_ids`, e.g. `"1,3,10"`, limits the evaluation to instances with the
|
||||
given IDs (comma separated).
|
||||
|
||||
Example:
|
||||
```bash
|
||||
./evaluation/integration_tests/scripts/run_infer.sh llm.claude-35-sonnet-eval HEAD CodeActAgent
|
||||
```
|
||||
@@ -1,251 +0,0 @@
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import os
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
get_openhands_config_for_eval,
|
||||
make_metadata,
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from evaluation.utils.shared import (
|
||||
codeact_user_response as fake_user_response,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
AgentConfig,
|
||||
OpenHandsConfig,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.serialization.event import event_to_dict
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
FAKE_RESPONSES = {
|
||||
'CodeActAgent': fake_user_response,
|
||||
'VisualBrowsingAgent': fake_user_response,
|
||||
}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
instance_id: str,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.platform = 'linux/amd64'
|
||||
config = get_openhands_config_for_eval(
|
||||
metadata=metadata,
|
||||
runtime=os.environ.get('RUNTIME', 'docker'),
|
||||
sandbox_config=sandbox_config,
|
||||
)
|
||||
config.debug = True
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
metadata.llm_config, metadata.eval_output_dir, instance_id
|
||||
)
|
||||
)
|
||||
agent_config = AgentConfig(
|
||||
enable_jupyter=True,
|
||||
enable_browsing=True,
|
||||
enable_llm_editor=False,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
return config
|
||||
|
||||
|
||||
def process_instance(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
) -> EvalOutput:
|
||||
config = get_config(metadata, instance.instance_id)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, str(instance.instance_id), log_dir)
|
||||
else:
|
||||
logger.info(
|
||||
f'\nStarting evaluation for instance {str(instance.instance_id)}.\n'
|
||||
)
|
||||
|
||||
# =============================================
|
||||
# import test instance
|
||||
# =============================================
|
||||
instance_id = instance.instance_id
|
||||
spec = importlib.util.spec_from_file_location(instance_id, instance.file_path)
|
||||
test_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(test_module)
|
||||
assert hasattr(test_module, 'Test'), (
|
||||
f'Test module {instance_id} does not have a Test class'
|
||||
)
|
||||
|
||||
test_class: type[BaseIntegrationTest] = test_module.Test
|
||||
assert issubclass(test_class, BaseIntegrationTest), (
|
||||
f'Test class {instance_id} does not inherit from BaseIntegrationTest'
|
||||
)
|
||||
|
||||
instruction = test_class.INSTRUCTION
|
||||
|
||||
# =============================================
|
||||
# create sandbox and run the agent
|
||||
# =============================================
|
||||
runtime: Runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
try:
|
||||
test_class.initialize_runtime(runtime)
|
||||
|
||||
# Here's how you can run the agent (similar to the `main` function) and get the final task state
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=MessageAction(content=instruction),
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
|
||||
)
|
||||
)
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
|
||||
# # =============================================
|
||||
# # result evaluation
|
||||
# # =============================================
|
||||
|
||||
histories = state.history
|
||||
|
||||
# some basic check
|
||||
logger.info(f'Total events in history: {len(histories)}')
|
||||
assert len(histories) > 0, 'History should not be empty'
|
||||
|
||||
test_result: TestResult = test_class.verify_result(runtime, histories)
|
||||
metrics = get_metrics(state)
|
||||
finally:
|
||||
runtime.close()
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
instance_id=str(instance.instance_id),
|
||||
instance=instance.to_dict(),
|
||||
instruction=instruction,
|
||||
metadata=metadata,
|
||||
history=[event_to_dict(event) for event in histories],
|
||||
metrics=metrics,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result=test_result.model_dump(),
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
def load_integration_tests() -> pd.DataFrame:
|
||||
"""Load tests from python files under ./tests"""
|
||||
cur_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
test_dir = os.path.join(cur_dir, 'tests')
|
||||
test_files = [
|
||||
os.path.join(test_dir, f)
|
||||
for f in os.listdir(test_dir)
|
||||
if f.startswith('t') and f.endswith('.py')
|
||||
]
|
||||
df = pd.DataFrame(test_files, columns=['file_path'])
|
||||
df['instance_id'] = df['file_path'].apply(
|
||||
lambda x: os.path.basename(x).rstrip('.py')
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
args, _ = parser.parse_known_args()
|
||||
integration_tests = load_integration_tests()
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'integration_tests',
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
args.eval_output_dir,
|
||||
)
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
|
||||
# Parse dataset IDs if provided
|
||||
eval_ids = None
|
||||
if args.eval_ids:
|
||||
eval_ids = str(args.eval_ids).split(',')
|
||||
logger.info(f'\nUsing specific dataset IDs: {eval_ids}\n')
|
||||
|
||||
instances = prepare_dataset(
|
||||
integration_tests,
|
||||
output_file,
|
||||
args.eval_n_limit,
|
||||
eval_ids=eval_ids,
|
||||
)
|
||||
|
||||
run_evaluation(
|
||||
instances,
|
||||
metadata,
|
||||
output_file,
|
||||
args.eval_num_workers,
|
||||
process_instance,
|
||||
)
|
||||
|
||||
df = pd.read_json(output_file, lines=True, orient='records')
|
||||
|
||||
# record success and reason
|
||||
df['success'] = df['test_result'].apply(lambda x: x['success'])
|
||||
df['reason'] = df['test_result'].apply(lambda x: x['reason'])
|
||||
logger.info('-' * 100)
|
||||
logger.info(
|
||||
f'Success rate: {df["success"].mean():.2%} ({df["success"].sum()}/{len(df)})'
|
||||
)
|
||||
logger.info(
|
||||
'\nEvaluation Results:'
|
||||
+ '\n'
|
||||
+ df[['instance_id', 'success', 'reason']].to_string(index=False)
|
||||
)
|
||||
logger.info('-' * 100)
|
||||
|
||||
# record cost for each instance, with 3 decimal places
|
||||
# we sum up all the "costs" from the metrics array
|
||||
df['cost'] = df['metrics'].apply(
|
||||
lambda m: round(sum(c['cost'] for c in m['costs']), 3)
|
||||
if m and 'costs' in m
|
||||
else 0.0
|
||||
)
|
||||
|
||||
# capture the top-level error if present, per instance
|
||||
df['error_message'] = df.get('error', None)
|
||||
|
||||
logger.info(f'Total cost: USD {df["cost"].sum():.2f}')
|
||||
|
||||
report_file = os.path.join(metadata.eval_output_dir, 'report.md')
|
||||
with open(report_file, 'w') as f:
|
||||
f.write(
|
||||
f'Success rate: {df["success"].mean():.2%}'
|
||||
f' ({df["success"].sum()}/{len(df)})\n'
|
||||
)
|
||||
f.write(f'\nTotal cost: USD {df["cost"].sum():.2f}\n')
|
||||
f.write(
|
||||
df[
|
||||
['instance_id', 'success', 'reason', 'cost', 'error_message']
|
||||
].to_markdown(index=False)
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "evaluation/utils/version_control.sh"
|
||||
|
||||
MODEL_CONFIG=$1
|
||||
COMMIT_HASH=$2
|
||||
AGENT=$3
|
||||
EVAL_LIMIT=$4
|
||||
MAX_ITERATIONS=$5
|
||||
NUM_WORKERS=$6
|
||||
EVAL_IDS=$7
|
||||
|
||||
if [ -z "$NUM_WORKERS" ]; then
|
||||
NUM_WORKERS=1
|
||||
echo "Number of workers not specified, use default $NUM_WORKERS"
|
||||
fi
|
||||
checkout_eval_branch
|
||||
|
||||
if [ -z "$AGENT" ]; then
|
||||
echo "Agent not specified, use default CodeActAgent"
|
||||
AGENT="CodeActAgent"
|
||||
fi
|
||||
|
||||
get_openhands_version
|
||||
|
||||
echo "AGENT: $AGENT"
|
||||
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
|
||||
echo "MODEL_CONFIG: $MODEL_CONFIG"
|
||||
|
||||
EVAL_NOTE=$OPENHANDS_VERSION
|
||||
|
||||
# Default to NOT use unit tests.
|
||||
if [ -z "$USE_UNIT_TESTS" ]; then
|
||||
export USE_UNIT_TESTS=false
|
||||
fi
|
||||
echo "USE_UNIT_TESTS: $USE_UNIT_TESTS"
|
||||
# If use unit tests, set EVAL_NOTE to the commit hash
|
||||
if [ "$USE_UNIT_TESTS" = true ]; then
|
||||
EVAL_NOTE=$EVAL_NOTE-w-test
|
||||
fi
|
||||
|
||||
# export PYTHONPATH=evaluation/integration_tests:\$PYTHONPATH
|
||||
COMMAND="poetry run python evaluation/integration_tests/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations ${MAX_ITERATIONS:-10} \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $EVAL_NOTE"
|
||||
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
if [ -n "$EVAL_IDS" ]; then
|
||||
echo "EVAL_IDS: $EVAL_IDS"
|
||||
COMMAND="$COMMAND --eval-ids $EVAL_IDS"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
@@ -1,32 +0,0 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.events.event import Event
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class TestResult(BaseModel):
|
||||
success: bool
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class BaseIntegrationTest(ABC):
|
||||
"""Base class for integration tests."""
|
||||
|
||||
INSTRUCTION: str
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
"""Initialize the runtime for the test to run."""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
"""Verify the result of the test.
|
||||
|
||||
This method will be called after the agent performs the task on the runtime.
|
||||
"""
|
||||
pass
|
||||
@@ -1,39 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Fix typos in bad.txt.'
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
# create a file with a typo in /workspace/bad.txt
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_file_path = os.path.join(temp_dir, 'bad.txt')
|
||||
with open(temp_file_path, 'w') as f:
|
||||
f.write('This is a stupid typoo.\nReally?\nNo mor typos!\nEnjoy!')
|
||||
|
||||
# Copy the file to the desired location
|
||||
runtime.copy_to(temp_file_path, '/workspace')
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
# check if the file /workspace/bad.txt has been fixed
|
||||
action = CmdRunAction(command='cat /workspace/bad.txt')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False, reason=f'Failed to run command: {obs.content}'
|
||||
)
|
||||
# check if the file /workspace/bad.txt has been fixed
|
||||
if (
|
||||
obs.content.strip().replace('\r\n', '\n')
|
||||
== 'This is a stupid typo.\nReally?\nNo more typos!\nEnjoy!'
|
||||
):
|
||||
return TestResult(success=True)
|
||||
return TestResult(success=False, reason=f'File not fixed: {obs.content}')
|
||||
@@ -1,40 +0,0 @@
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from evaluation.utils.shared import assert_and_raise
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = "Write a shell script '/workspace/hello.sh' that prints 'hello'."
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
# check if the file /workspace/hello.sh exists
|
||||
action = CmdRunAction(command='cat /workspace/hello.sh')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to cat /workspace/hello.sh: {obs.content}.',
|
||||
)
|
||||
|
||||
# execute the script
|
||||
action = CmdRunAction(command='bash /workspace/hello.sh')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to execute /workspace/hello.sh: {obs.content}.',
|
||||
)
|
||||
if obs.content.strip() != 'hello':
|
||||
return TestResult(
|
||||
success=False, reason=f'Script did not print "hello": {obs.content}.'
|
||||
)
|
||||
return TestResult(success=True)
|
||||
@@ -1,43 +0,0 @@
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from evaluation.utils.shared import assert_and_raise
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = "Use Jupyter IPython to write a text file containing 'hello world' to '/workspace/test.txt'."
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
# check if the file /workspace/hello.sh exists
|
||||
action = CmdRunAction(command='cat /workspace/test.txt')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
|
||||
)
|
||||
|
||||
# execute the script
|
||||
action = CmdRunAction(command='cat /workspace/test.txt')
|
||||
obs = runtime.run_action(action)
|
||||
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to cat /workspace/test.txt: {obs.content}.',
|
||||
)
|
||||
|
||||
if 'hello world' not in obs.content.strip():
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'File did not contain "hello world": {obs.content}.',
|
||||
)
|
||||
return TestResult(success=True)
|
||||
@@ -1,57 +0,0 @@
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from evaluation.utils.shared import assert_and_raise
|
||||
from openhands.events.action import CmdRunAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Write a git commit message for the current staging area and commit the changes.'
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# git init
|
||||
action = CmdRunAction(command='git init')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# create file
|
||||
action = CmdRunAction(command='echo \'print("hello world")\' > hello.py')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# git add
|
||||
cmd_str = 'git add hello.py'
|
||||
action = CmdRunAction(command=cmd_str)
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
# check if the file /workspace/hello.py exists
|
||||
action = CmdRunAction(command='cat /workspace/hello.py')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
|
||||
)
|
||||
|
||||
# check if the staging area is empty
|
||||
action = CmdRunAction(command='git status')
|
||||
obs = runtime.run_action(action)
|
||||
if obs.exit_code != 0:
|
||||
return TestResult(
|
||||
success=False, reason=f'Failed to git status: {obs.content}.'
|
||||
)
|
||||
if 'nothing to commit, working tree clean' in obs.content.strip():
|
||||
return TestResult(success=True)
|
||||
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'Failed to check for "nothing to commit, working tree clean": {obs.content}.',
|
||||
)
|
||||
@@ -1,145 +0,0 @@
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from evaluation.utils.shared import assert_and_raise
|
||||
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentDelegateObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
HTML_FILE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>The Ultimate Answer</title>
|
||||
<style>
|
||||
body {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
background: linear-gradient(to right, #1e3c72, #2a5298);
|
||||
color: #fff;
|
||||
font-family: 'Arial', sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
#showButton {
|
||||
padding: 10px 20px;
|
||||
font-size: 16px;
|
||||
color: #1e3c72;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
#showButton:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
#result {
|
||||
margin-top: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>The Ultimate Answer</h1>
|
||||
<p>Click the button to reveal the answer to life, the universe, and everything.</p>
|
||||
<button id="showButton">Click me</button>
|
||||
<div id="result"></div>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('showButton').addEventListener('click', function() {
|
||||
document.getElementById('result').innerText = 'The answer is OpenHands is all you need!';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Browse localhost:8000, and tell me the ultimate answer to life.'
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
action = CmdRunAction(command='mkdir -p /tmp/server')
|
||||
obs = runtime.run_action(action)
|
||||
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
|
||||
|
||||
# create a file with a typo in /workspace/bad.txt
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_file_path = os.path.join(temp_dir, 'index.html')
|
||||
with open(temp_file_path, 'w') as f:
|
||||
f.write(HTML_FILE)
|
||||
# Copy the file to the desired location
|
||||
runtime.copy_to(temp_file_path, '/tmp/server')
|
||||
|
||||
# create README.md
|
||||
action = CmdRunAction(
|
||||
command='cd /tmp/server && nohup python3 -m http.server 8000 &'
|
||||
)
|
||||
obs = runtime.run_action(action)
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# check if the "The answer is OpenHands is all you need!" is in any message
|
||||
message_actions = [
|
||||
event
|
||||
for event in histories
|
||||
if isinstance(
|
||||
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
|
||||
)
|
||||
]
|
||||
logger.debug(f'Total message-like events: {len(message_actions)}')
|
||||
|
||||
for event in message_actions:
|
||||
try:
|
||||
if isinstance(event, AgentDelegateObservation):
|
||||
content = event.content
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
content = event.outputs.get('content', '')
|
||||
elif isinstance(event, MessageAction):
|
||||
content = event.content
|
||||
else:
|
||||
logger.warning(f'Unexpected event type: {type(event)}')
|
||||
continue
|
||||
|
||||
if 'OpenHands is all you need!' in content:
|
||||
return TestResult(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing event: {e}')
|
||||
|
||||
logger.debug(
|
||||
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
|
||||
)
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from openhands.events.action import AgentFinishAction, MessageAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentDelegateObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Look at https://github.com/OpenHands/OpenHands/pull/8, and tell me what is happening there and what did @asadm suggest.'
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# check if the license information is in any message
|
||||
message_actions = [
|
||||
event
|
||||
for event in histories
|
||||
if isinstance(
|
||||
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
|
||||
)
|
||||
]
|
||||
logger.info(f'Total message-like events: {len(message_actions)}')
|
||||
|
||||
for event in message_actions:
|
||||
try:
|
||||
if isinstance(event, AgentDelegateObservation):
|
||||
content = event.content
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
content = event.outputs.get('content', '')
|
||||
if event.thought:
|
||||
content += f'\n\n{event.thought}'
|
||||
elif isinstance(event, MessageAction):
|
||||
content = event.content
|
||||
else:
|
||||
logger.warning(f'Unexpected event type: {type(event)}')
|
||||
continue
|
||||
|
||||
if (
|
||||
'non-commercial' in content
|
||||
or 'MIT' in content
|
||||
or 'Apache 2.0' in content
|
||||
):
|
||||
return TestResult(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing event: {e}')
|
||||
|
||||
logger.debug(
|
||||
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
|
||||
)
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
|
||||
)
|
||||
@@ -1,73 +0,0 @@
|
||||
import hashlib
|
||||
|
||||
from evaluation.integration_tests.tests.base import BaseIntegrationTest, TestResult
|
||||
from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
FileWriteAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentDelegateObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
|
||||
|
||||
class Test(BaseIntegrationTest):
|
||||
INSTRUCTION = 'Execute the python script /workspace/python_script.py with input "John" and "25" and tell me the secret number.'
|
||||
SECRET_NUMBER = int(hashlib.sha256(str(25).encode()).hexdigest()[:8], 16) % 1000
|
||||
|
||||
@classmethod
|
||||
def initialize_runtime(cls, runtime: Runtime) -> None:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
action = FileWriteAction(
|
||||
path='/workspace/python_script.py',
|
||||
content=(
|
||||
'name = input("Enter your name: "); age = input("Enter your age: "); '
|
||||
'import hashlib; secret = int(hashlib.sha256(str(age).encode()).hexdigest()[:8], 16) % 1000; '
|
||||
'print(f"Hello {name}, you are {age} years old. Tell you a secret number: {secret}")'
|
||||
),
|
||||
)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
observation = runtime.run_action(action)
|
||||
logger.info(observation, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
@classmethod
|
||||
def verify_result(cls, runtime: Runtime, histories: list[Event]) -> TestResult:
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
# check if the license information is in any message
|
||||
message_actions = [
|
||||
event
|
||||
for event in histories
|
||||
if isinstance(
|
||||
event, (MessageAction, AgentFinishAction, AgentDelegateObservation)
|
||||
)
|
||||
]
|
||||
logger.info(f'Total message-like events: {len(message_actions)}')
|
||||
|
||||
for event in message_actions:
|
||||
try:
|
||||
if isinstance(event, AgentDelegateObservation):
|
||||
content = event.content
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
content = event.outputs.get('content', '')
|
||||
if event.thought:
|
||||
content += f'\n\n{event.thought}'
|
||||
elif isinstance(event, MessageAction):
|
||||
content = event.content
|
||||
else:
|
||||
logger.warning(f'Unexpected event type: {type(event)}')
|
||||
continue
|
||||
|
||||
if str(cls.SECRET_NUMBER) in content:
|
||||
return TestResult(success=True)
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing event: {e}')
|
||||
|
||||
logger.debug(
|
||||
f'Total messages: {len(message_actions)}. Messages: {message_actions}'
|
||||
)
|
||||
return TestResult(
|
||||
success=False,
|
||||
reason=f'The answer is not found in any message. Total messages: {len(message_actions)}.',
|
||||
)
|
||||
@@ -33,9 +33,24 @@ describe("AccountSettingsContextMenu", () => {
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("SIDEBAR$DOCS")).toBeInTheDocument();
|
||||
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render Documentation link with correct attributes", () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={onLogoutMock}
|
||||
onClose={onCloseMock}
|
||||
/>,
|
||||
);
|
||||
|
||||
const documentationLink = screen.getByText("SIDEBAR$DOCS").closest("a");
|
||||
expect(documentationLink).toHaveAttribute("href", "https://docs.openhands.dev");
|
||||
expect(documentationLink).toHaveAttribute("target", "_blank");
|
||||
expect(documentationLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should call onLogout when the logout option is clicked", async () => {
|
||||
renderWithRouter(
|
||||
<AccountSettingsContextMenu
|
||||
|
||||
@@ -8,6 +8,13 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
|
||||
}));
|
||||
|
||||
// Mock the useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
|
||||
@@ -8,10 +8,11 @@ vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"TASK_TRACKING_OBSERVATION$TASK_LIST": "Task List",
|
||||
"TASK_TRACKING_OBSERVATION$TASK_ID": "ID",
|
||||
"TASK_TRACKING_OBSERVATION$TASK_NOTES": "Notes",
|
||||
"TASK_TRACKING_OBSERVATION$RESULT": "Result",
|
||||
TASK_TRACKING_OBSERVATION$TASK_LIST: "Task List",
|
||||
TASK_TRACKING_OBSERVATION$TASK_ID: "ID",
|
||||
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
|
||||
TASK_TRACKING_OBSERVATION$RESULT: "Result",
|
||||
COMMON$TASKS: "Tasks",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -61,19 +62,26 @@ describe("TaskTrackingObservationContent", () => {
|
||||
it("renders task list when command is 'plan' and tasks exist", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Task List (3 items)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays correct status icons and badges", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
const { container } = render(
|
||||
<TaskTrackingObservationContent event={mockEvent} />,
|
||||
);
|
||||
|
||||
// Check for status text (the icons are emojis)
|
||||
expect(screen.getByText("todo")).toBeInTheDocument();
|
||||
expect(screen.getByText("in progress")).toBeInTheDocument();
|
||||
expect(screen.getByText("done")).toBeInTheDocument();
|
||||
// Status is represented by icons, not text. Verify task items are rendered with their titles
|
||||
// which indicates the status icons are present (status affects icon rendering)
|
||||
expect(screen.getByText("Implement feature A")).toBeInTheDocument();
|
||||
expect(screen.getByText("Fix bug B")).toBeInTheDocument();
|
||||
expect(screen.getByText("Deploy to production")).toBeInTheDocument();
|
||||
|
||||
// Verify task items are present (they contain the status icons)
|
||||
const taskItems = container.querySelectorAll('[data-name="item"]');
|
||||
expect(taskItems).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("displays task IDs and notes", () => {
|
||||
@@ -84,14 +92,9 @@ describe("TaskTrackingObservationContent", () => {
|
||||
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
|
||||
expect(screen.getByText("Notes: Completed successfully")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders result section when content exists", () => {
|
||||
render(<TaskTrackingObservationContent event={mockEvent} />);
|
||||
|
||||
expect(screen.getByText("Result")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task tracking operation completed successfully")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Notes: Completed successfully"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render task list when command is not 'plan'", () => {
|
||||
@@ -105,7 +108,7 @@ describe("TaskTrackingObservationContent", () => {
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithoutPlan} />);
|
||||
|
||||
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render task list when task list is empty", () => {
|
||||
@@ -119,17 +122,6 @@ describe("TaskTrackingObservationContent", () => {
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithEmptyTasks} />);
|
||||
|
||||
expect(screen.queryByText("Task List")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render result section when content is empty", () => {
|
||||
const eventWithoutContent = {
|
||||
...mockEvent,
|
||||
content: "",
|
||||
};
|
||||
|
||||
render(<TaskTrackingObservationContent event={eventWithoutContent} />);
|
||||
|
||||
expect(screen.queryByText("Result")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Tasks")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,34 +13,6 @@ vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({
|
||||
conversationId: "test-conversation-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
@@ -66,8 +38,12 @@ vi.mock("react-i18next", async () => {
|
||||
COMMON$SERVER_STOPPED: "Server Stopped",
|
||||
COMMON$ERROR: "Error",
|
||||
COMMON$STARTING: "Starting",
|
||||
COMMON$STOPPING: "Stopping...",
|
||||
COMMON$STOP_RUNTIME: "Stop Runtime",
|
||||
COMMON$START_RUNTIME: "Start Runtime",
|
||||
CONVERSATION$ERROR_STARTING_CONVERSATION:
|
||||
"Error starting conversation",
|
||||
CONVERSATION$READY: "Ready",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -79,10 +55,6 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
@@ -94,248 +66,91 @@ describe("ServerStatus", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render server status with different conversation statuses", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
it("should render server status with RUNNING conversation status", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render server status with STOPPED conversation status", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu when clicked with RUNNING status", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("should render STARTING status when agent state is LOADING", () => {
|
||||
mockAgentStore(AgentState.LOADING);
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render STARTING status when agent state is INIT", () => {
|
||||
mockAgentStore(AgentState.INIT);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render ERROR status when agent state is ERROR", () => {
|
||||
mockAgentStore(AgentState.ERROR);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render STOPPING status when isPausing is true", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" isPausing={true} />,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should appear
|
||||
expect(
|
||||
screen.getByTestId("server-status-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu when clicked with STOPPED status", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should appear
|
||||
expect(
|
||||
screen.getByTestId("server-status-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show context menu when clicked with other statuses", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should not appear
|
||||
expect(
|
||||
screen.queryByTestId("server-status-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call stop conversation mutation when stop server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleStop.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleResumeAgent.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
// Context menu should be closed
|
||||
expect(
|
||||
screen.queryByTestId("server-status-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stopping...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null conversation status", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" className="custom-class" />,
|
||||
);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
const container = screen.getByTestId("server-status");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerStatusContextMenu", () => {
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
});
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
conversationStatus: "RUNNING" as ConversationStatus,
|
||||
@@ -346,6 +161,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render stop server button when status is RUNNING", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -354,11 +171,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render start server button when status is STOPPED", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -367,11 +187,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Start Runtime")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render stop server button when onStopServer is not provided", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -379,10 +202,13 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render start server button when onStartServer is not provided", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -390,12 +216,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onStopServer when stop button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStopServer = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -414,6 +242,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
it("should call onStartServer when start button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStartServer = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -430,6 +259,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for stop server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -444,6 +275,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for start server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -459,6 +292,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
|
||||
it("should call onClose when context menu is closed", () => {
|
||||
const onClose = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -475,6 +309,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should not render any buttons for other conversation statuses", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -482,6 +318,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
const mockUseCreateConversationAndSubscribeMultiple = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -47,6 +48,17 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-create-conversation-and-subscribe-multiple", () => ({
|
||||
useCreateConversationAndSubscribeMultiple: () =>
|
||||
mockUseCreateConversationAndSubscribeMultiple(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -309,6 +321,16 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversationAndSubscribeMultiple.mockReturnValue({
|
||||
createConversationAndSubscribe: vi.fn(({ onSuccessCallback }) => {
|
||||
// Immediately call the success callback to close the modal
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback();
|
||||
}
|
||||
}),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
// Mock the search repositories hook to return repositories with OpenHands suffixes
|
||||
const mockSearchResults =
|
||||
getRepositoriesWithOpenHandsSuffix(mockRepositories);
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("ImagePreview", () => {
|
||||
expect(onRemoveMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shoud not display the close button when onRemove is not provided", () => {
|
||||
it("should not display the close button when onRemove is not provided", () => {
|
||||
render(<ImagePreview src="https://example.com/image.jpg" />);
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import AcceptTOS from "#/routes/accept-tos";
|
||||
import * as CaptureConsent from "#/utils/handle-capture-consent";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
|
||||
// Mock the react-router hooks
|
||||
@@ -44,9 +43,13 @@ const createWrapper = () => {
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return Wrapper;
|
||||
};
|
||||
|
||||
describe("AcceptTOS", () => {
|
||||
@@ -106,7 +109,10 @@ describe("AcceptTOS", () => {
|
||||
// Wait for the mutation to complete
|
||||
await new Promise(process.nextTick);
|
||||
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(true);
|
||||
expect(handleCaptureConsentSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
true,
|
||||
);
|
||||
expect(openHands.post).toHaveBeenCalledWith("/api/accept_tos", {
|
||||
redirect_url: "/dashboard",
|
||||
});
|
||||
|
||||
@@ -46,6 +46,21 @@ describe("Content", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render analytics toggle as enabled when server returns null (opt-in by default)", async () => {
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
user_consents_to_analytics: null,
|
||||
});
|
||||
|
||||
renderAppSettingsScreen();
|
||||
|
||||
await waitFor(() => {
|
||||
const analytics = screen.getByTestId("enable-analytics-switch");
|
||||
expect(analytics).toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the language options", async () => {
|
||||
renderAppSettingsScreen();
|
||||
|
||||
@@ -163,7 +178,10 @@ describe("Form submission", () => {
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
true,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -188,7 +206,10 @@ describe("Form submission", () => {
|
||||
await userEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
|
||||
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("Error Handler", () => {
|
||||
const error = {
|
||||
message: "Test error",
|
||||
source: "test",
|
||||
posthog,
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
@@ -52,6 +53,7 @@ describe("Error Handler", () => {
|
||||
extra: "info",
|
||||
details: { foo: "bar" },
|
||||
},
|
||||
posthog,
|
||||
};
|
||||
|
||||
trackError(error);
|
||||
@@ -73,6 +75,7 @@ describe("Error Handler", () => {
|
||||
const error = {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
posthog,
|
||||
};
|
||||
|
||||
showErrorToast(error);
|
||||
@@ -94,6 +97,7 @@ describe("Error Handler", () => {
|
||||
message: "Toast error",
|
||||
source: "toast-test",
|
||||
metadata: { context: "testing" },
|
||||
posthog,
|
||||
};
|
||||
|
||||
showErrorToast(error);
|
||||
@@ -113,6 +117,7 @@ describe("Error Handler", () => {
|
||||
message: "Agent error",
|
||||
source: "agent-status",
|
||||
metadata: { id: "error.agent" },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
@@ -127,6 +132,7 @@ describe("Error Handler", () => {
|
||||
message: "Server error",
|
||||
source: "server",
|
||||
metadata: { error_code: 500, details: "Internal error" },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
@@ -145,6 +151,7 @@ describe("Error Handler", () => {
|
||||
message: error.message,
|
||||
source: "feedback",
|
||||
metadata: { conversationId: "123", error },
|
||||
posthog,
|
||||
});
|
||||
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
@@ -164,6 +171,7 @@ describe("Error Handler", () => {
|
||||
message: "Chat error",
|
||||
source: "chat-test",
|
||||
msgId: "123",
|
||||
posthog,
|
||||
};
|
||||
|
||||
showChatError(error);
|
||||
|
||||
@@ -13,14 +13,14 @@ describe("handleCaptureConsent", () => {
|
||||
});
|
||||
|
||||
it("should opt out of of capturing", () => {
|
||||
handleCaptureConsent(false);
|
||||
handleCaptureConsent(posthog, false);
|
||||
|
||||
expect(optOutSpy).toHaveBeenCalled();
|
||||
expect(optInSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should opt in to capturing if the user consents", () => {
|
||||
handleCaptureConsent(true);
|
||||
handleCaptureConsent(posthog, true);
|
||||
|
||||
expect(optInSpy).toHaveBeenCalled();
|
||||
expect(optOutSpy).not.toHaveBeenCalled();
|
||||
@@ -28,7 +28,7 @@ describe("handleCaptureConsent", () => {
|
||||
|
||||
it("should not opt in to capturing if the user is already opted in", () => {
|
||||
hasOptedInSpy.mockReturnValueOnce(true);
|
||||
handleCaptureConsent(true);
|
||||
handleCaptureConsent(posthog, true);
|
||||
|
||||
expect(optInSpy).not.toHaveBeenCalled();
|
||||
expect(optOutSpy).not.toHaveBeenCalled();
|
||||
@@ -36,7 +36,7 @@ describe("handleCaptureConsent", () => {
|
||||
|
||||
it("should not opt out of capturing if the user is already opted out", () => {
|
||||
hasOptedOutSpy.mockReturnValueOnce(true);
|
||||
handleCaptureConsent(false);
|
||||
handleCaptureConsent(posthog, false);
|
||||
|
||||
expect(optOutSpy).not.toHaveBeenCalled();
|
||||
expect(optInSpy).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getStatusCode, getIndicatorColor, IndicatorColor } from "../status";
|
||||
import { getStatusCode, getIndicatorColor, IndicatorColor } from "#/utils/status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -87,6 +87,36 @@ describe("getStatusCode", () => {
|
||||
// Should return runtime status since no agent state
|
||||
expect(result).toBe("STATUS$STARTING_RUNTIME");
|
||||
});
|
||||
|
||||
it("should prioritize task ERROR status over websocket CONNECTING state", () => {
|
||||
// Test case: Task has errored but websocket is still trying to connect
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTING", // webSocketStatus (stuck connecting)
|
||||
null, // conversationStatus
|
||||
null, // runtimeStatus
|
||||
AgentState.LOADING, // agentState
|
||||
"ERROR", // taskStatus (ERROR)
|
||||
);
|
||||
|
||||
// Should return error message, not "Connecting..."
|
||||
expect(result).toBe(I18nKey.AGENT_STATUS$ERROR_OCCURRED);
|
||||
});
|
||||
|
||||
it("should show Connecting when task is working and websocket is connecting", () => {
|
||||
// Test case: Task is in progress and websocket is connecting normally
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTING", // webSocketStatus
|
||||
null, // conversationStatus
|
||||
null, // runtimeStatus
|
||||
AgentState.LOADING, // agentState
|
||||
"WORKING", // taskStatus (in progress)
|
||||
);
|
||||
|
||||
// Should show connecting message since task hasn't errored
|
||||
expect(result).toBe(I18nKey.CHAT_INTERFACE$CONNECTING);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndicatorColor", () => {
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@posthog/react": "^1.4.0",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
@@ -38,7 +39,7 @@
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.268.8",
|
||||
"posthog-js": "^1.290.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -3511,9 +3512,29 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.2.2.tgz",
|
||||
"integrity": "sha512-f16Ozx6LIigRG+HsJdt+7kgSxZTHeX5f1JlCGKI1lXcvlZgfsCR338FuMI2QRYXGl+jg/vYFzGOTQBxl90lnBg=="
|
||||
"version": "1.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.5.2.tgz",
|
||||
"integrity": "sha512-iedUP3EnOPPxTA2VaIrsrd29lSZnUV+ZrMnvY56timRVeZAXoYCkmjfIs3KBAsF8OUT5h1GXLSkoQdrV0r31OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "^7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/react": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/react/-/react-1.4.0.tgz",
|
||||
"integrity": "sha512-xzPeZ753fQ0deZzdgY/0YavZvNpmdaxUzLYJYu5XjONNcZ8PwJnNLEK+7D/Cj8UM4Q8nWI7QC5mjum0uLWa4FA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8.0",
|
||||
"posthog-js": ">=1.257.2",
|
||||
"react": ">=16.8.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@react-aria/breadcrumbs": {
|
||||
"version": "3.5.28",
|
||||
@@ -8183,7 +8204,6 @@
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
@@ -8198,7 +8218,6 @@
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
@@ -11403,7 +11422,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/istanbul-lib-coverage": {
|
||||
@@ -14073,7 +14091,6 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -14264,27 +14281,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.268.8",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.268.8.tgz",
|
||||
"integrity": "sha512-BJiKK4MlUvs7ybnQcy1KkwAz+SZkE/wRLotetIoank5kbqZs8FLbeyozFvmmgx4aoMmaVymYBSmYphYjYQeidw==",
|
||||
"version": "1.290.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.290.0.tgz",
|
||||
"integrity": "sha512-zavBwZkf+3JeiSDVE7ZDXBfzva/iOljicdhdJH+cZoqp0LsxjKxjnNhGOd3KpAhw0wqdwjhd7Lp1aJuI7DXyaw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "1.2.2",
|
||||
"@posthog/core": "1.5.2",
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
"web-vitals": "^4.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rrweb/types": "2.0.0-alpha.17",
|
||||
"rrweb-snapshot": "2.0.0-alpha.17"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rrweb/types": {
|
||||
"optional": true
|
||||
},
|
||||
"rrweb-snapshot": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/web-vitals": {
|
||||
@@ -15547,7 +15553,6 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
@@ -15560,7 +15565,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.61.0",
|
||||
"version": "0.62.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -11,6 +11,7 @@
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@posthog/react": "^1.4.0",
|
||||
"@react-router/node": "^7.9.3",
|
||||
"@react-router/serve": "^7.9.3",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
@@ -37,7 +38,7 @@
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.544.0",
|
||||
"monaco-editor": "^0.53.0",
|
||||
"posthog-js": "^1.268.8",
|
||||
"posthog-js": "^1.290.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
|
||||
@@ -60,6 +60,8 @@ class V1ConversationService {
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
trigger?: ConversationTrigger,
|
||||
parent_conversation_id?: string,
|
||||
agent_type?: "default" | "plan",
|
||||
): Promise<V1AppConversationStartTask> {
|
||||
const body: V1AppConversationStartRequest = {
|
||||
selected_repository: selectedRepository,
|
||||
@@ -67,6 +69,8 @@ class V1ConversationService {
|
||||
selected_branch,
|
||||
title: conversationInstructions,
|
||||
trigger,
|
||||
parent_conversation_id: parent_conversation_id || null,
|
||||
agent_type,
|
||||
};
|
||||
|
||||
// Add initial message if provided
|
||||
@@ -111,11 +115,11 @@ class V1ConversationService {
|
||||
* Search for start tasks (ongoing tasks that haven't completed yet)
|
||||
* Use this to find tasks that were started but the user navigated away
|
||||
*
|
||||
* Note: Backend only supports filtering by limit. To filter by repository/trigger,
|
||||
* Note: Backend supports filtering by limit and created_at__gte. To filter by repository/trigger,
|
||||
* filter the results client-side after fetching.
|
||||
*
|
||||
* @param limit Maximum number of tasks to return (max 100)
|
||||
* @returns Array of start tasks
|
||||
* @returns Array of start tasks from the last 20 minutes
|
||||
*/
|
||||
static async searchStartTasks(
|
||||
limit: number = 100,
|
||||
@@ -123,6 +127,10 @@ class V1ConversationService {
|
||||
const params = new URLSearchParams();
|
||||
params.append("limit", limit.toString());
|
||||
|
||||
// Only get tasks from the last 20 minutes
|
||||
const twentyMinutesAgo = new Date(Date.now() - 20 * 60 * 1000);
|
||||
params.append("created_at__gte", twentyMinutesAgo.toISOString());
|
||||
|
||||
const { data } = await openHands.get<V1AppConversationStartTaskPage>(
|
||||
`/api/v1/app-conversations/start-tasks/search?${params.toString()}`,
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface V1AppConversationStartRequest {
|
||||
title?: string | null;
|
||||
trigger?: ConversationTrigger | null;
|
||||
pr_number?: number[];
|
||||
parent_conversation_id?: string | null;
|
||||
agent_type?: "default" | "plan";
|
||||
}
|
||||
|
||||
export type V1AppConversationStartTaskStatus =
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface Conversation {
|
||||
session_api_key: string | null;
|
||||
pr_number?: number[] | null;
|
||||
conversation_version?: "V0" | "V1";
|
||||
sub_conversation_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ResultSet<T> {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import {
|
||||
BaseModalTitle,
|
||||
BaseModalDescription,
|
||||
@@ -17,6 +18,7 @@ interface AnalyticsConsentFormModalProps {
|
||||
export function AnalyticsConsentFormModal({
|
||||
onClose,
|
||||
}: AnalyticsConsentFormModalProps) {
|
||||
const posthog = usePostHog();
|
||||
const { t } = useTranslation();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
@@ -29,7 +31,7 @@ export function AnalyticsConsentFormModal({
|
||||
{ user_consents_to_analytics: analytics },
|
||||
{
|
||||
onSuccess: () => {
|
||||
handleCaptureConsent(analytics);
|
||||
handleCaptureConsent(posthog, analytics);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
|
||||
182
frontend/src/components/features/chat/change-agent-button.tsx
Normal file
182
frontend/src/components/features/chat/change-agent-button.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useMemo, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import CodeTagIcon from "#/icons/code-tag.svg?react";
|
||||
import ChevronDownSmallIcon from "#/icons/chevron-down-small.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { ChangeAgentContextMenu } from "./change-agent-context-menu";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
|
||||
|
||||
export function ChangeAgentButton() {
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState<boolean>(false);
|
||||
|
||||
const conversationMode = useConversationStore(
|
||||
(state) => state.conversationMode,
|
||||
);
|
||||
|
||||
const setConversationMode = useConversationStore(
|
||||
(state) => state.setConversationMode,
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isAgentRunning = curAgentState === AgentState.RUNNING;
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { mutate: createConversation, isPending: isCreatingConversation } =
|
||||
useCreateConversation();
|
||||
|
||||
// Close context menu when agent starts running
|
||||
useEffect(() => {
|
||||
if (isAgentRunning && contextMenuOpen) {
|
||||
setContextMenuOpen(false);
|
||||
}
|
||||
}, [isAgentRunning, contextMenuOpen]);
|
||||
|
||||
const handlePlanClick = (
|
||||
event: React.MouseEvent<HTMLButtonElement> | KeyboardEvent,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Set conversation mode to "plan" immediately
|
||||
setConversationMode("plan");
|
||||
|
||||
// Check if sub_conversation_ids is not empty
|
||||
if (
|
||||
(conversation?.sub_conversation_ids &&
|
||||
conversation.sub_conversation_ids.length > 0) ||
|
||||
!conversation?.conversation_id
|
||||
) {
|
||||
// Do nothing if both conditions are true
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new sub-conversation if we have a current conversation ID
|
||||
createConversation(
|
||||
{
|
||||
parentConversationId: conversation.conversation_id,
|
||||
agentType: "plan",
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
displaySuccessToast(
|
||||
t(I18nKey.PLANNING_AGENTT$PLANNING_AGENT_INITIALIZED),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
// Handle Shift + Tab keyboard shortcut to cycle through modes
|
||||
useEffect(() => {
|
||||
if (!shouldUsePlanningAgent || isAgentRunning) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Check for Shift + Tab combination
|
||||
if (event.shiftKey && event.key === "Tab") {
|
||||
// Prevent default tab navigation behavior
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Cycle between modes: code -> plan -> code
|
||||
const nextMode = conversationMode === "code" ? "plan" : "code";
|
||||
if (nextMode === "plan") {
|
||||
handlePlanClick(event);
|
||||
} else {
|
||||
setConversationMode(nextMode);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
shouldUsePlanningAgent,
|
||||
isAgentRunning,
|
||||
conversationMode,
|
||||
setConversationMode,
|
||||
]);
|
||||
|
||||
const handleButtonClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setContextMenuOpen(!contextMenuOpen);
|
||||
};
|
||||
|
||||
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setConversationMode("code");
|
||||
};
|
||||
|
||||
const isExecutionAgent = conversationMode === "code";
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (isExecutionAgent) {
|
||||
return t(I18nKey.COMMON$CODE);
|
||||
}
|
||||
return t(I18nKey.COMMON$PLAN);
|
||||
}, [isExecutionAgent, t]);
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (isExecutionAgent) {
|
||||
return <CodeTagIcon width={18} height={18} color="#737373" />;
|
||||
}
|
||||
return <LessonPlanIcon width={18} height={18} color="#ffffff" />;
|
||||
}, [isExecutionAgent]);
|
||||
|
||||
const isButtonDisabled = isAgentRunning || isCreatingConversation;
|
||||
|
||||
if (!shouldUsePlanningAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleButtonClick}
|
||||
disabled={isButtonDisabled}
|
||||
className={cn(
|
||||
"flex items-center border border-[#4B505F] rounded-[100px] transition-opacity",
|
||||
!isExecutionAgent && "border-[#597FF4] bg-[#4A67BD]",
|
||||
isButtonDisabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "cursor-pointer hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1 pl-1.5">
|
||||
{buttonIcon}
|
||||
<Typography.Text className="text-white text-2.75 not-italic font-normal leading-5">
|
||||
{buttonLabel}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<ChevronDownSmallIcon width={24} height={24} color="#ffffff" />
|
||||
</button>
|
||||
{contextMenuOpen && (
|
||||
<ChangeAgentContextMenu
|
||||
onClose={() => setContextMenuOpen(false)}
|
||||
onCodeClick={handleCodeClick}
|
||||
onPlanClick={handlePlanClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import CodeTagIcon from "#/icons/code-tag.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
|
||||
import { ContextMenuIconTextWithDescription } from "../context-menu/context-menu-icon-text-with-description";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
const contextMenuListItemClassName = cn(
|
||||
"cursor-pointer p-0 h-auto hover:bg-transparent",
|
||||
);
|
||||
|
||||
interface ChangeAgentContextMenuProps {
|
||||
onClose: () => void;
|
||||
onCodeClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onPlanClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
}
|
||||
|
||||
export function ChangeAgentContextMenu({
|
||||
onClose,
|
||||
onCodeClick,
|
||||
onPlanClick,
|
||||
}: ChangeAgentContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const handleCodeClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onCodeClick?.(event);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePlanClick = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onPlanClick?.(event);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={menuRef}
|
||||
testId="change-agent-context-menu"
|
||||
position="top"
|
||||
alignment="left"
|
||||
className="min-h-fit mb-2 min-w-[195px] max-w-[195px] gap-0"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
testId="code-option"
|
||||
onClick={handleCodeClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconTextWithDescription
|
||||
icon={CodeTagIcon}
|
||||
title={t(I18nKey.COMMON$CODE)}
|
||||
description={t(I18nKey.COMMON$CODE_AGENT_DESCRIPTION)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
<ContextMenuListItem
|
||||
testId="plan-option"
|
||||
onClick={handlePlanClick}
|
||||
className={contextMenuListItemClassName}
|
||||
>
|
||||
<ContextMenuIconTextWithDescription
|
||||
icon={LessonPlanIcon}
|
||||
title={t(I18nKey.COMMON$PLAN)}
|
||||
description={t(I18nKey.COMMON$PLAN_AGENT_DESCRIPTION)}
|
||||
/>
|
||||
</ContextMenuListItem>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
|
||||
@@ -60,6 +60,7 @@ function getEntryPoint(
|
||||
}
|
||||
|
||||
export function ChatInterface() {
|
||||
const posthog = usePostHog();
|
||||
const { setMessageToSend } = useConversationStore();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
|
||||
@@ -1,45 +1,33 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversation";
|
||||
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
|
||||
import { ChangeAgentButton } from "../change-agent-button";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleResumeAgent,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const v1PauseConversationMutation = useV1PauseConversation();
|
||||
const v1ResumeConversationMutation = useV1ResumeConversation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Pause the conversation (agent execution)
|
||||
@@ -62,10 +50,6 @@ export function ChatInputActions({
|
||||
handleResumeAgent();
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing =
|
||||
pauseConversationSandboxMutation.isPending ||
|
||||
v1PauseConversationMutation.isPending;
|
||||
@@ -73,13 +57,10 @@ export function ChatInputActions({
|
||||
return (
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<Tools />
|
||||
<ChangeAgentButton />
|
||||
</div>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
import { ChatInputActions } from "./chat-input-actions";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -11,7 +12,6 @@ interface ChatInputContainerProps {
|
||||
disabled: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
@@ -32,7 +32,6 @@ export function ChatInputContainer({
|
||||
disabled,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
conversationStatus,
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
@@ -46,10 +45,17 @@ export function ChatInputContainer({
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ChatInputContainerProps) {
|
||||
const conversationMode = useConversationStore(
|
||||
(state) => state.conversationMode,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
|
||||
className={cn(
|
||||
"bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full",
|
||||
conversationMode === "plan" && "border border-[#597FF4]",
|
||||
)}
|
||||
onDragOver={(e) => onDragOver(e, disabled)}
|
||||
onDragLeave={(e) => onDragLeave(e, disabled)}
|
||||
onDrop={(e) => onDrop(e, disabled)}
|
||||
@@ -74,7 +80,6 @@ export function ChatInputContainer({
|
||||
/>
|
||||
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
|
||||
interface ChatInputFieldProps {
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -20,6 +22,12 @@ export function ChatInputField({
|
||||
}: ChatInputFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const conversationMode = useConversationStore(
|
||||
(state) => state.conversationMode,
|
||||
);
|
||||
|
||||
const isPlanMode = conversationMode === "plan";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
|
||||
@@ -30,7 +38,11 @@ export function ChatInputField({
|
||||
ref={chatInputRef}
|
||||
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
|
||||
contentEditable
|
||||
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
|
||||
data-placeholder={
|
||||
isPlanMode
|
||||
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)
|
||||
: t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)
|
||||
}
|
||||
data-testid="chat-input"
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
|
||||
@@ -137,7 +137,6 @@ export function CustomChatInput({
|
||||
disabled={isDisabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
conversationStatus={conversationStatus}
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { isTaskTrackingObservation } from "#/types/core/guards";
|
||||
import { GenericEventMessage } from "../generic-event-message";
|
||||
import { TaskTrackingObservationContent } from "../task-tracking-observation-content";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
|
||||
interface TaskTrackingEventMessageProps {
|
||||
event: OpenHandsObservation;
|
||||
@@ -16,34 +12,13 @@ export function TaskTrackingEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
}: TaskTrackingEventMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isTaskTrackingObservation(event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { command } = event.extras;
|
||||
let title: React.ReactNode;
|
||||
let initiallyExpanded = false;
|
||||
|
||||
// Determine title and expansion state based on command
|
||||
if (command === "plan") {
|
||||
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_PLAN");
|
||||
initiallyExpanded = true;
|
||||
} else {
|
||||
// command === "view"
|
||||
title = t("OBSERVATION_MESSAGE$TASK_TRACKING_VIEW");
|
||||
initiallyExpanded = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
title={title}
|
||||
details={<TaskTrackingObservationContent event={event} />}
|
||||
success={getObservationResult(event)}
|
||||
initiallyExpanded={initiallyExpanded}
|
||||
/>
|
||||
<TaskTrackingObservationContent event={event} />
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import PRIcon from "#/icons/u-pr.svg?react";
|
||||
import { cn, getCreatePRPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPrButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,6 +20,7 @@ export function GitControlBarPrButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackCreatePrButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function GitControlBarPrButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePrClick = () => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
trackCreatePrButtonClick();
|
||||
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowDownIcon from "#/icons/u-arrow-down.svg?react";
|
||||
import { cn, getGitPullPrompt } from "#/utils/utils";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPullButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -16,6 +16,7 @@ export function GitControlBarPullButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPullButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPullButtonClick } = useTracking();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { providers } = useUserProviders();
|
||||
@@ -26,7 +27,7 @@ export function GitControlBarPullButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePullClick = () => {
|
||||
posthog.capture("pull_button_clicked");
|
||||
trackPullButtonClick();
|
||||
onSuggestionsClick(getGitPullPrompt());
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowUpIcon from "#/icons/u-arrow-up.svg?react";
|
||||
import { cn, getGitPushPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPushButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,6 +20,7 @@ export function GitControlBarPushButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPushButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPushButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function GitControlBarPushButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePushClick = () => {
|
||||
posthog.capture("push_button_clicked");
|
||||
trackPushButtonClick();
|
||||
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
82
frontend/src/components/features/chat/plan-preview.tsx
Normal file
82
frontend/src/components/features/chat/plan-preview.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface PlanPreviewProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
onViewClick?: () => void;
|
||||
onBuildClick?: () => void;
|
||||
}
|
||||
|
||||
// TODO: Remove the hardcoded values and use the plan content from the conversation store
|
||||
/* eslint-disable i18next/no-literal-string */
|
||||
export function PlanPreview({
|
||||
title = "Improve Developer Onboarding and Examples",
|
||||
description = "Based on the analysis of Browser-Use's current documentation and examples, this plan addresses gaps in developer onboarding by creating a progressive learning path, troubleshooting resources, and practical examples that address real-world scenarios (like the LM Studio/local LLM integration issues encountered...",
|
||||
onViewClick,
|
||||
onBuildClick,
|
||||
}: PlanPreviewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
if (!shouldUsePlanningAgent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[#25272d] border border-[#597FF4] rounded-[12px] w-full mb-4 mt-2">
|
||||
{/* Header */}
|
||||
<div className="border-b border-[#525252] flex h-[41px] items-center px-2 gap-1">
|
||||
<LessonPlanIcon width={18} height={18} color="#9299aa" />
|
||||
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
|
||||
{t(I18nKey.COMMON$PLAN_MD)}
|
||||
</Typography.Text>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={onViewClick}
|
||||
className="flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Typography.Text className="font-medium text-[11px] text-white tracking-[0.11px] leading-4">
|
||||
{t(I18nKey.COMMON$VIEW)}
|
||||
</Typography.Text>
|
||||
<ArrowUpRight className="text-white" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-col gap-[10px] p-4">
|
||||
<h3 className="font-bold text-[19px] text-white leading-[29px]">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-[15px] text-white leading-[29px]">
|
||||
{description}
|
||||
<Typography.Text className="text-[#4a67bd] cursor-pointer hover:underline ml-1">
|
||||
{t(I18nKey.COMMON$READ_MORE)}
|
||||
</Typography.Text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-[#525252] flex h-[54px] items-center justify-start px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuildClick}
|
||||
className="bg-white flex items-center justify-center h-[26px] px-2 rounded-[4px] w-[93px] hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
<Typography.Text className="font-medium text-[14px] text-black leading-5">
|
||||
{t(I18nKey.COMMON$BUILD)}{" "}
|
||||
<Typography.Text className="font-medium text-black">
|
||||
⌘↩
|
||||
</Typography.Text>
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TaskTrackingObservation } from "#/types/core/observations";
|
||||
import { TaskListSection } from "./task-tracking/task-list-section";
|
||||
import { ResultSection } from "./task-tracking/result-section";
|
||||
|
||||
interface TaskTrackingObservationContentProps {
|
||||
event: TaskTrackingObservation;
|
||||
@@ -16,11 +15,6 @@ export function TaskTrackingObservationContent({
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Task List section - only show for 'plan' command */}
|
||||
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
||||
|
||||
{/* Result message - only show if there's meaningful content */}
|
||||
{event.content && event.content.trim() && (
|
||||
<ResultSection content={event.content} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface ResultSectionProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function ResultSection({ content }: ResultSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>{t("TASK_TRACKING_OBSERVATION$RESULT")}</Typography.H3>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 shadow-inner">
|
||||
<pre className="whitespace-pre-wrap text-sm">{content.trim()}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import CircleIcon from "#/icons/u-circle.svg?react";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import LoadingIcon from "#/icons/loading.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { StatusIcon } from "./status-icon";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: {
|
||||
@@ -10,33 +14,47 @@ interface TaskItemProps {
|
||||
status: "todo" | "in_progress" | "done";
|
||||
notes?: string;
|
||||
};
|
||||
index: number;
|
||||
}
|
||||
|
||||
export function TaskItem({ task, index }: TaskItemProps) {
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (task.status) {
|
||||
case "todo":
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "in_progress":
|
||||
return <LoadingIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "done":
|
||||
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
|
||||
default:
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
}
|
||||
}, [task.status]);
|
||||
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-gray-600 pl-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StatusIcon status={task.status} />
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Typography.Text className="text-sm text-gray-400">
|
||||
{index + 1}.
|
||||
</Typography.Text>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
<h4 className="font-medium text-white mb-1">{task.title}</h4>
|
||||
<Typography.Text className="text-xs text-gray-400 mb-1">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_ID")}: {task.id}
|
||||
</Typography.Text>
|
||||
{task.notes && (
|
||||
<Typography.Text className="text-sm text-gray-300 italic">
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_NOTES")}: {task.notes}
|
||||
</Typography.Text>
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
)}
|
||||
</div>
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
@@ -15,19 +17,20 @@ export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Typography.H3>
|
||||
{t("TASK_TRACKING_OBSERVATION$TASK_LIST")} ({taskList.length}{" "}
|
||||
{taskList.length === 1 ? "item" : "items"})
|
||||
</Typography.H3>
|
||||
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
|
||||
{/* Header Tabs */}
|
||||
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
|
||||
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
|
||||
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
||||
{t(I18nKey.COMMON$TASKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<div className="space-y-3">
|
||||
{taskList.map((task, index) => (
|
||||
<TaskItem key={task.id} task={task} index={index} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task Items */}
|
||||
<div>
|
||||
{taskList.map((task) => (
|
||||
<TaskItem key={task.id} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import LogOutIcon from "#/icons/log-out.svg?react";
|
||||
import DocumentIcon from "#/icons/document.svg?react";
|
||||
import { SAAS_NAV_ITEMS, OSS_NAV_ITEMS } from "#/constants/settings-nav";
|
||||
|
||||
interface AccountSettingsContextMenuProps {
|
||||
@@ -58,6 +59,21 @@ export function AccountSettingsContextMenu({
|
||||
|
||||
<Divider />
|
||||
|
||||
<a
|
||||
href="https://docs.openhands.dev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-decoration-none"
|
||||
>
|
||||
<ContextMenuListItem
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<DocumentIcon width={16} height={16} />
|
||||
<span className="text-white text-sm">{t(I18nKey.SIDEBAR$DOCS)}</span>
|
||||
</ContextMenuListItem>
|
||||
</a>
|
||||
|
||||
<ContextMenuListItem
|
||||
onClick={onLogout}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import { ContextMenuIconText } from "./context-menu-icon-text";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ContextMenuIconTextWithDescriptionProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description: string;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
}
|
||||
|
||||
export function ContextMenuIconTextWithDescription({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
iconClassName,
|
||||
}: ContextMenuIconTextWithDescriptionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1 justify-center hover:bg-[#5C5D62] rounded p-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ContextMenuIconText
|
||||
icon={icon}
|
||||
text={title}
|
||||
className="px-0"
|
||||
iconClassName={iconClassName}
|
||||
/>
|
||||
<Typography.Text className="text-[#A3A3A3] text-[10px] font-normal whitespace-pre-wrap break-words">
|
||||
{description}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
export interface AgentStatusProps {
|
||||
className?: string;
|
||||
@@ -35,6 +36,7 @@ export function AgentStatus({
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { taskStatus } = useTaskPolling();
|
||||
|
||||
const statusCode = getStatusCode(
|
||||
curStatusMessage,
|
||||
@@ -42,17 +44,24 @@ export function AgentStatus({
|
||||
conversation?.status || null,
|
||||
conversation?.runtime_status || null,
|
||||
curAgentState,
|
||||
taskStatus,
|
||||
);
|
||||
|
||||
const isTaskLoading =
|
||||
taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY";
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
webSocketStatus === "CONNECTING";
|
||||
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
|
||||
isTaskLoading;
|
||||
|
||||
const shouldShownAgentError =
|
||||
curAgentState === AgentState.ERROR ||
|
||||
curAgentState === AgentState.RATE_LIMITED;
|
||||
curAgentState === AgentState.RATE_LIMITED ||
|
||||
webSocketStatus === "DISCONNECTED" ||
|
||||
taskStatus === "ERROR";
|
||||
|
||||
const shouldShownAgentStop = curAgentState === AgentState.RUNNING;
|
||||
|
||||
@@ -61,7 +70,7 @@ export function AgentStatus({
|
||||
|
||||
// Update global state when agent loading condition changes
|
||||
useEffect(() => {
|
||||
setShouldShownAgentLoading(shouldShownAgentLoading);
|
||||
setShouldShownAgentLoading(!!shouldShownAgentLoading);
|
||||
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({
|
||||
}: ServerStatusContextMenuIconTextProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
className="flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
onClick={onClick}
|
||||
data-testid={testId}
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,9 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import StopCircleIcon from "#/icons/stop-circle.svg?react";
|
||||
import PlayCircleIcon from "#/icons/play-circle.svg?react";
|
||||
import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text";
|
||||
import { ServerStatus } from "./server-status";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ServerStatusContextMenuProps {
|
||||
onClose: () => void;
|
||||
@@ -13,6 +16,8 @@ interface ServerStatusContextMenuProps {
|
||||
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
position?: "top" | "bottom";
|
||||
className?: string;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function ServerStatusContextMenu({
|
||||
@@ -21,10 +26,15 @@ export function ServerStatusContextMenu({
|
||||
onStartServer,
|
||||
conversationStatus,
|
||||
position = "top",
|
||||
className = "",
|
||||
isPausing = false,
|
||||
}: ServerStatusContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const shouldActionShown =
|
||||
conversationStatus === "RUNNING" || conversationStatus === "STOPPED";
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
@@ -32,24 +42,36 @@ export function ServerStatusContextMenu({
|
||||
position={position}
|
||||
alignment="left"
|
||||
size="default"
|
||||
className="left-2 w-fit min-w-max"
|
||||
className={cn("left-2 w-fit min-w-42", className)}
|
||||
>
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
className="py-1"
|
||||
/>
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
{shouldActionShown && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
@@ -34,34 +27,15 @@ export function ServerStatus({
|
||||
|
||||
const isStopStatus = conversationStatus === "STOPPED";
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
const statusColor = getStatusColor({
|
||||
isPausing,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
if (isStopStatus) {
|
||||
return "#ffffff";
|
||||
}
|
||||
if (curAgentState === AgentState.ERROR) {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#BCFF8C";
|
||||
};
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
@@ -100,49 +74,14 @@ export function ServerStatus({
|
||||
return t(I18nKey.COMMON$RUNNING);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (conversationStatus === "RUNNING" || conversationStatus === "STOPPED") {
|
||||
setShowContextMenu(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor();
|
||||
const statusText = getStatusText();
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="flex items-center cursor-pointer" onClick={handleClick}>
|
||||
<div className={className} data-testid="server-status">
|
||||
<div className="flex items-center">
|
||||
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
|
||||
<span className="text-[11px] text-white font-normal leading-5">
|
||||
{statusText}
|
||||
</span>
|
||||
<span className="text-[13px] text-white font-normal">{statusText}</span>
|
||||
</div>
|
||||
|
||||
{showContextMenu && (
|
||||
<ServerStatusContextMenu
|
||||
onClose={handleCloseContextMenu}
|
||||
onStopServer={handleStopServer}
|
||||
onStartServer={handleStartServer}
|
||||
conversationStatus={conversationStatus}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
@@ -44,6 +44,7 @@ export function ConversationCard({
|
||||
contextMenuOpen = false,
|
||||
onContextMenuToggle,
|
||||
}: ConversationCardProps) {
|
||||
const posthog = usePostHog();
|
||||
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
|
||||
|
||||
const onTitleSave = (newTitle: string) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
|
||||
import { ConversationName } from "./conversation-name";
|
||||
|
||||
export function ConversationNameWithStatus() {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { isTask, taskStatus } = useTaskPolling();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: resumeConversationSandbox } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
const isStopStatus = conversation?.status === "STOPPED";
|
||||
|
||||
const statusColor = getStatusColor({
|
||||
isPausing: false,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
pauseConversationSandbox({ conversationId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
resumeConversationSandbox({ conversationId, providers });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="group relative">
|
||||
<DebugStackframeDot
|
||||
className="ml-[3.5px] w-6 h-6 cursor-pointer"
|
||||
color={statusColor}
|
||||
/>
|
||||
<ServerStatusContextMenu
|
||||
onClose={() => {}}
|
||||
onStopServer={
|
||||
conversation?.status === "RUNNING" ? handleStopServer : undefined
|
||||
}
|
||||
onStartServer={
|
||||
conversation?.status === "STOPPED" ? handleStartServer : undefined
|
||||
}
|
||||
conversationStatus={conversation?.status ?? null}
|
||||
position="bottom"
|
||||
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
|
||||
isPausing={false}
|
||||
/>
|
||||
</div>
|
||||
<ConversationName />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function ConversationName() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-3.5"
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-1"
|
||||
data-testid="conversation-name"
|
||||
>
|
||||
{titleMode === "edit" ? (
|
||||
|
||||
@@ -15,6 +15,7 @@ const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
|
||||
@@ -28,6 +29,7 @@ export function ConversationTabContent() {
|
||||
const isServedActive = selectedTab === "served";
|
||||
const isVSCodeActive = selectedTab === "vscode";
|
||||
const isTerminalActive = selectedTab === "terminal";
|
||||
const isPlannerActive = selectedTab === "planner";
|
||||
|
||||
// Define tab configurations
|
||||
const tabs = [
|
||||
@@ -44,6 +46,11 @@ export function ConversationTabContent() {
|
||||
component: Terminal,
|
||||
isActive: isTerminalActive,
|
||||
},
|
||||
{
|
||||
key: "planner",
|
||||
component: PlannerTab,
|
||||
isActive: isPlannerActive,
|
||||
},
|
||||
];
|
||||
|
||||
const conversationTabTitle = useMemo(() => {
|
||||
@@ -62,6 +69,9 @@ export function ConversationTabContent() {
|
||||
if (isTerminalActive) {
|
||||
return t(I18nKey.COMMON$TERMINAL);
|
||||
}
|
||||
if (isPlannerActive) {
|
||||
return t(I18nKey.COMMON$PLANNER);
|
||||
}
|
||||
return "";
|
||||
}, [
|
||||
isEditorActive,
|
||||
@@ -69,6 +79,7 @@ export function ConversationTabContent() {
|
||||
isServedActive,
|
||||
isVSCodeActive,
|
||||
isTerminalActive,
|
||||
isPlannerActive,
|
||||
]);
|
||||
|
||||
if (shouldShownAgentLoading) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../../context-menu/context-menu-list-item";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
|
||||
interface ConversationTabsContextMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConversationTabsContextMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ConversationTabsContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
[],
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: "editor",
|
||||
icon: GitChanges,
|
||||
i18nKey: I18nKey.COMMON$CHANGES,
|
||||
},
|
||||
{
|
||||
tab: "vscode",
|
||||
icon: VSCodeIcon,
|
||||
i18nKey: I18nKey.COMMON$CODE,
|
||||
},
|
||||
{
|
||||
tab: "terminal",
|
||||
icon: TerminalIcon,
|
||||
i18nKey: I18nKey.COMMON$TERMINAL,
|
||||
},
|
||||
{
|
||||
tab: "served",
|
||||
icon: ServerIcon,
|
||||
i18nKey: I18nKey.COMMON$APP,
|
||||
},
|
||||
{
|
||||
tab: "browser",
|
||||
icon: GlobeIcon,
|
||||
i18nKey: I18nKey.COMMON$BROWSER,
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldUsePlanningAgent) {
|
||||
tabConfig.unshift({
|
||||
tab: "planner",
|
||||
icon: LessonPlanIcon,
|
||||
i18nKey: I18nKey.COMMON$PLANNER,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTabClick = (tab: string) => {
|
||||
const tabString = tab;
|
||||
if (unpinnedTabs.includes(tabString)) {
|
||||
// Tab is unpinned, pin it (remove from unpinned list)
|
||||
setUnpinnedTabs(
|
||||
unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString),
|
||||
);
|
||||
} else {
|
||||
// Tab is pinned, unpin it (add to unpinned list)
|
||||
setUnpinnedTabs([...unpinnedTabs, tabString]);
|
||||
}
|
||||
};
|
||||
|
||||
const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
testId="conversation-tabs-context-menu"
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
position="bottom"
|
||||
className="mt-2 w-fit z-[9999]"
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
|
||||
const pinned = isTabPinned(tab);
|
||||
return (
|
||||
<ContextMenuListItem
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-white text-sm">{t(i18nKey)}</span>
|
||||
{pinned ? (
|
||||
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
|
||||
) : (
|
||||
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
|
||||
)}
|
||||
</ContextMenuListItem>
|
||||
);
|
||||
})}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
@@ -6,6 +6,8 @@ import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
|
||||
@@ -15,6 +17,8 @@ import {
|
||||
useConversationStore,
|
||||
type ConversationTab,
|
||||
} from "#/state/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const {
|
||||
@@ -24,6 +28,8 @@ export function ConversationTabs() {
|
||||
setSelectedTab,
|
||||
} = useConversationStore();
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Persist selectedTab and isRightPanelShown in localStorage
|
||||
const [persistedSelectedTab, setPersistedSelectedTab] =
|
||||
useLocalStorage<ConversationTab | null>(
|
||||
@@ -34,6 +40,13 @@ export function ConversationTabs() {
|
||||
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
|
||||
useLocalStorage<boolean>("conversation-right-panel-shown", true);
|
||||
|
||||
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
[],
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const onTabChange = (value: ConversationTab | null) => {
|
||||
setSelectedTab(value);
|
||||
// Persist the selected tab to localStorage
|
||||
@@ -87,6 +100,7 @@ export function ConversationTabs() {
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
tabValue: "editor",
|
||||
isActive: isTabActive("editor"),
|
||||
icon: GitChanges,
|
||||
onClick: () => onTabSelected("editor"),
|
||||
@@ -95,6 +109,7 @@ export function ConversationTabs() {
|
||||
label: t(I18nKey.COMMON$CHANGES),
|
||||
},
|
||||
{
|
||||
tabValue: "vscode",
|
||||
isActive: isTabActive("vscode"),
|
||||
icon: VSCodeIcon,
|
||||
onClick: () => onTabSelected("vscode"),
|
||||
@@ -103,6 +118,7 @@ export function ConversationTabs() {
|
||||
label: t(I18nKey.COMMON$CODE),
|
||||
},
|
||||
{
|
||||
tabValue: "terminal",
|
||||
isActive: isTabActive("terminal"),
|
||||
icon: TerminalIcon,
|
||||
onClick: () => onTabSelected("terminal"),
|
||||
@@ -112,6 +128,7 @@ export function ConversationTabs() {
|
||||
className: "pl-2",
|
||||
},
|
||||
{
|
||||
tabValue: "served",
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
onClick: () => onTabSelected("served"),
|
||||
@@ -120,6 +137,7 @@ export function ConversationTabs() {
|
||||
label: t(I18nKey.COMMON$APP),
|
||||
},
|
||||
{
|
||||
tabValue: "browser",
|
||||
isActive: isTabActive("browser"),
|
||||
icon: GlobeIcon,
|
||||
onClick: () => onTabSelected("browser"),
|
||||
@@ -129,6 +147,23 @@ export function ConversationTabs() {
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldUsePlanningAgent) {
|
||||
tabs.unshift({
|
||||
tabValue: "planner",
|
||||
isActive: isTabActive("planner"),
|
||||
icon: LessonPlanIcon,
|
||||
onClick: () => onTabSelected("planner"),
|
||||
tooltipContent: t(I18nKey.COMMON$PLANNER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
|
||||
label: t(I18nKey.COMMON$PLANNER),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out unpinned tabs
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !persistedUnpinnedTabs.includes(tab.tabValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -136,7 +171,7 @@ export function ConversationTabs() {
|
||||
"flex flex-row justify-start lg:justify-end items-center gap-4.5",
|
||||
)}
|
||||
>
|
||||
{tabs.map(
|
||||
{visibleTabs.map(
|
||||
(
|
||||
{
|
||||
icon,
|
||||
@@ -164,6 +199,23 @@ export function ConversationTabs() {
|
||||
</ChatActionTooltip>
|
||||
),
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={cn(
|
||||
"p-1 pl-0 rounded-md cursor-pointer",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
)}
|
||||
aria-label={t(I18nKey.COMMON$MORE_OPTIONS)}
|
||||
>
|
||||
<ThreeDotsVerticalIcon className={cn("w-5 h-5 text-inherit")} />
|
||||
</button>
|
||||
<ConversationTabsContextMenu
|
||||
isOpen={isMenuOpen}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
80
frontend/src/components/features/markdown/headings.tsx
Normal file
80
frontend/src/components/features/markdown/headings.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import { ExtraProps } from "react-markdown";
|
||||
|
||||
// Custom component to render <h1> in markdown
|
||||
export function h1({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h1 className="text-[32px] text-white font-bold leading-8 mb-4 mt-6 first:mt-0">
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render <h2> in markdown
|
||||
export function h2({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h2 className="text-xl font-semibold leading-6 -tracking-[0.02em] text-white mb-3 mt-5 first:mt-0">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render <h3> in markdown
|
||||
export function h3({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h3 className="text-lg font-semibold text-white mb-2 mt-4 first:mt-0">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render <h4> in markdown
|
||||
export function h4({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h4 className="text-base font-semibold text-white mb-2 mt-4 first:mt-0">
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render <h5> in markdown
|
||||
export function h5({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h5 className="text-sm font-semibold text-white mb-2 mt-3 first:mt-0">
|
||||
{children}
|
||||
</h5>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom component to render <h6> in markdown
|
||||
export function h6({
|
||||
children,
|
||||
}: React.ClassAttributes<HTMLHeadingElement> &
|
||||
React.HTMLAttributes<HTMLHeadingElement> &
|
||||
ExtraProps) {
|
||||
return (
|
||||
<h6 className="text-sm font-medium text-gray-300 mb-2 mt-3 first:mt-0">
|
||||
{children}
|
||||
</h6>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface AuthModalProps {
|
||||
githubAuthUrl: string | null;
|
||||
@@ -26,6 +27,7 @@ export function AuthModal({
|
||||
providersConfigured,
|
||||
}: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackLoginButtonClick } = useTracking();
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
@@ -47,6 +49,7 @@ export function AuthModal({
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "github" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = githubAuthUrl;
|
||||
}
|
||||
@@ -54,6 +57,7 @@ export function AuthModal({
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "gitlab" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = gitlabAuthUrl;
|
||||
}
|
||||
@@ -61,6 +65,7 @@ export function AuthModal({
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "bitbucket" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = bitbucketAuthUrl;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export function AuthModal({
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoUrl) {
|
||||
trackLoginButtonClick({ provider: "enterprise_sso" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = enterpriseSsoUrl;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
|
||||
import { DangerModal } from "../confirmation-modals/danger-modal";
|
||||
@@ -22,6 +22,7 @@ interface SettingsFormProps {
|
||||
}
|
||||
|
||||
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
|
||||
const posthog = usePostHog();
|
||||
const { mutate: saveUserSettings } = useSaveSettings();
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Trans } from "react-i18next";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import React from "react";
|
||||
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { MonoComponent } from "../../../features/chat/mono-component";
|
||||
import { PathComponent } from "../../../features/chat/path-component";
|
||||
import { getActionContent } from "./get-action-content";
|
||||
import { getObservationContent } from "./get-observation-content";
|
||||
import { TaskTrackingObservationContent } from "../task-tracking/task-tracking-observation-content";
|
||||
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import i18n from "#/i18n";
|
||||
|
||||
const trimText = (text: string, maxLength: number): string => {
|
||||
@@ -158,14 +161,24 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
|
||||
export const getEventContent = (event: OpenHandsEvent) => {
|
||||
let title: React.ReactNode = "";
|
||||
let details: string = "";
|
||||
let details: string | React.ReactNode = "";
|
||||
|
||||
if (isActionEvent(event)) {
|
||||
title = getActionEventTitle(event);
|
||||
details = getActionContent(event);
|
||||
} else if (isObservationEvent(event)) {
|
||||
title = getObservationEventTitle(event);
|
||||
details = getObservationContent(event);
|
||||
|
||||
// For TaskTrackerObservation, use React component instead of markdown
|
||||
if (event.observation.kind === "TaskTrackerObservation") {
|
||||
details = (
|
||||
<TaskTrackingObservationContent
|
||||
event={event as ObservationEvent<TaskTrackerObservation>}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
details = getObservationContent(event);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,10 @@ const getExecuteBashObservationContent = (
|
||||
|
||||
let { output } = observation;
|
||||
|
||||
if (!output) {
|
||||
output = "";
|
||||
}
|
||||
|
||||
if (output.length > MAX_CONTENT_LENGTH) {
|
||||
output = `${output.slice(0, MAX_CONTENT_LENGTH)}...`;
|
||||
}
|
||||
@@ -136,6 +140,7 @@ const getTaskTrackerObservationContent = (
|
||||
if (
|
||||
"content" in observation &&
|
||||
observation.content &&
|
||||
typeof observation.content === "string" &&
|
||||
observation.content.trim()
|
||||
) {
|
||||
content += `\n\n**Result:** ${observation.content.trim()}`;
|
||||
|
||||
@@ -27,13 +27,16 @@ export function FinishEventMessage({
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
}: FinishEventMessageProps) {
|
||||
const eventContent = getEventContent(event);
|
||||
// For FinishAction, details is always a string (getActionContent returns string)
|
||||
const message =
|
||||
typeof eventContent.details === "string"
|
||||
? eventContent.details
|
||||
: String(eventContent.details);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
<ChatMessage type="agent" message={message} actions={actions} />
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
|
||||
@@ -16,6 +16,13 @@ export function GenericEventMessageWrapper({
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
if (
|
||||
isObservationEvent(event) &&
|
||||
event.observation.kind === "TaskTrackerObservation"
|
||||
) {
|
||||
return <div>{details}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<GenericEventMessage
|
||||
|
||||
56
frontend/src/components/v1/chat/task-tracking/task-item.tsx
Normal file
56
frontend/src/components/v1/chat/task-tracking/task-item.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
|
||||
import CircleIcon from "#/icons/u-circle.svg?react";
|
||||
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
|
||||
import LoadingIcon from "#/icons/loading.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface TaskItemProps {
|
||||
task: TaskItemType;
|
||||
}
|
||||
|
||||
export function TaskItem({ task }: TaskItemProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (task.status) {
|
||||
case "todo":
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
case "in_progress":
|
||||
return (
|
||||
<LoadingIcon className="w-4 h-4 text-[#ffffff]" strokeWidth={0.5} />
|
||||
);
|
||||
case "done":
|
||||
return <CheckCircleIcon className="w-4 h-4 text-[#A3A3A3]" />;
|
||||
default:
|
||||
return <CircleIcon className="w-4 h-4 text-[#ffffff]" />;
|
||||
}
|
||||
}, [task.status]);
|
||||
|
||||
const isDoneStatus = task.status === "done";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-[14px] items-center px-4 py-2 w-full"
|
||||
data-name="item"
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
|
||||
<Typography.Text
|
||||
className={cn(
|
||||
"text-[12px] text-white",
|
||||
isDoneStatus && "text-[#A3A3A3]",
|
||||
)}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="text-[10px] text-[#A3A3A3]">
|
||||
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskItem } from "./task-item";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { TaskItem as TaskItemType } from "#/types/v1/core/base/common";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Typography } from "#/ui/typography";
|
||||
|
||||
interface TaskListSectionProps {
|
||||
taskList: TaskItemType[];
|
||||
}
|
||||
|
||||
export function TaskListSection({ taskList }: TaskListSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-clip bg-[#25272d] border border-[#525252] rounded-[12px] w-full">
|
||||
{/* Header Tabs */}
|
||||
<div className="flex gap-1 items-center border-b border-[#525252] h-[41px] px-2 shrink-0">
|
||||
<LessonPlanIcon className="shrink-0 w-4.5 h-4.5 text-[#9299aa]" />
|
||||
<Typography.Text className="text-[11px] text-nowrap text-white tracking-[0.11px] font-medium leading-[16px] whitespace-pre">
|
||||
{t(I18nKey.COMMON$TASKS)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
{/* Task Items */}
|
||||
<div>
|
||||
{taskList.map((task, index) => (
|
||||
<TaskItem key={`task-${index}`} task={task} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from "react";
|
||||
import { ObservationEvent } from "#/types/v1/core";
|
||||
import { TaskTrackerObservation } from "#/types/v1/core/base/observation";
|
||||
import { TaskListSection } from "./task-list-section";
|
||||
|
||||
interface TaskTrackingObservationContentProps {
|
||||
event: ObservationEvent<TaskTrackerObservation>;
|
||||
}
|
||||
|
||||
export function TaskTrackingObservationContent({
|
||||
event,
|
||||
}: TaskTrackingObservationContentProps): React.ReactNode {
|
||||
const { observation } = event;
|
||||
const { command, task_list: taskList } = observation;
|
||||
const shouldShowTaskList = command === "plan" && taskList.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Task List section - only show for 'plan' command */}
|
||||
{shouldShowTaskList && <TaskListSection taskList={taskList} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import EventLogger from "#/utils/event-logger";
|
||||
import { handleAssistantMessage } from "#/services/actions";
|
||||
import { showChatError, trackError } from "#/utils/error-handler";
|
||||
@@ -100,7 +101,10 @@ interface ErrorArgData {
|
||||
msg_id: string;
|
||||
}
|
||||
|
||||
export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
export function updateStatusWhenErrorMessagePresent(
|
||||
data: ErrorArg | unknown,
|
||||
posthog?: ReturnType<typeof usePostHog>,
|
||||
) {
|
||||
const isObject = (val: unknown): val is object =>
|
||||
!!val && typeof val === "object";
|
||||
const isString = (val: unknown): val is string => typeof val === "string";
|
||||
@@ -123,6 +127,7 @@ export function updateStatusWhenErrorMessagePresent(data: ErrorArg | unknown) {
|
||||
source: "websocket",
|
||||
metadata,
|
||||
msgId,
|
||||
posthog,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,11 +136,13 @@ export function WsClientProvider({
|
||||
conversationId,
|
||||
children,
|
||||
}: React.PropsWithChildren<WsClientProviderProps>) {
|
||||
const posthog = usePostHog();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { addEvent, clearEvents } = useEventStore();
|
||||
const queryClient = useQueryClient();
|
||||
const sioRef = React.useRef<Socket | null>(null);
|
||||
const pendingEventsRef = React.useRef<Record<string, unknown>[]>([]);
|
||||
const [webSocketStatus, setWebSocketStatus] =
|
||||
React.useState<V0_WebSocketStatus>("DISCONNECTED");
|
||||
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
|
||||
@@ -145,17 +152,37 @@ export function WsClientProvider({
|
||||
const { data: conversation, refetch: refetchConversation } =
|
||||
useActiveConversation();
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
if (!sioRef.current) {
|
||||
EventLogger.error("WebSocket is not connected.");
|
||||
function flushPendingEvents(socket: Socket | null = sioRef.current) {
|
||||
if (!socket || pendingEventsRef.current.length === 0) {
|
||||
return;
|
||||
}
|
||||
sioRef.current.emit("oh_user_action", event);
|
||||
|
||||
pendingEventsRef.current.forEach((queuedEvent) => {
|
||||
socket.emit("oh_user_action", queuedEvent);
|
||||
});
|
||||
pendingEventsRef.current = [];
|
||||
}
|
||||
|
||||
function send(event: Record<string, unknown>) {
|
||||
const socket = sioRef.current;
|
||||
|
||||
if (!socket) {
|
||||
EventLogger.error("WebSocket is not connected, queuing message...");
|
||||
pendingEventsRef.current.push(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingEventsRef.current.length > 0) {
|
||||
flushPendingEvents(socket);
|
||||
}
|
||||
|
||||
socket.emit("oh_user_action", event);
|
||||
}
|
||||
|
||||
function handleConnect() {
|
||||
setWebSocketStatus("CONNECTED");
|
||||
removeErrorMessage();
|
||||
flushPendingEvents();
|
||||
}
|
||||
|
||||
function handleMessage(event: Record<string, unknown>) {
|
||||
@@ -178,6 +205,7 @@ export function WsClientProvider({
|
||||
message: errorMessage,
|
||||
source: "chat",
|
||||
metadata: { msgId: event.id },
|
||||
posthog,
|
||||
});
|
||||
setErrorMessage(errorMessage);
|
||||
|
||||
@@ -193,6 +221,7 @@ export function WsClientProvider({
|
||||
message: event.message,
|
||||
source: "chat",
|
||||
metadata: { msgId: event.id },
|
||||
posthog,
|
||||
});
|
||||
} else {
|
||||
removeErrorMessage();
|
||||
@@ -260,14 +289,14 @@ export function WsClientProvider({
|
||||
sio.io.opts.query = sio.io.opts.query || {};
|
||||
sio.io.opts.query.latest_event_id = lastEventRef.current?.id;
|
||||
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
updateStatusWhenErrorMessagePresent(data, posthog);
|
||||
setErrorMessage(hasValidMessageProperty(data) ? data.message : "");
|
||||
}
|
||||
|
||||
function handleError(data: unknown) {
|
||||
// set status
|
||||
setWebSocketStatus("DISCONNECTED");
|
||||
updateStatusWhenErrorMessagePresent(data);
|
||||
updateStatusWhenErrorMessagePresent(data, posthog);
|
||||
|
||||
setErrorMessage(
|
||||
hasValidMessageProperty(data)
|
||||
@@ -284,6 +313,7 @@ export function WsClientProvider({
|
||||
|
||||
clearEvents();
|
||||
setWebSocketStatus("CONNECTING");
|
||||
pendingEventsRef.current = [];
|
||||
}, [conversationId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -293,6 +323,12 @@ export function WsClientProvider({
|
||||
|
||||
// Clear error messages when conversation is intentionally stopped
|
||||
if (conversation && conversation.status === "STOPPED") {
|
||||
const existingSocket = sioRef.current;
|
||||
if (existingSocket) {
|
||||
existingSocket.disconnect();
|
||||
}
|
||||
sioRef.current = null;
|
||||
pendingEventsRef.current = [];
|
||||
removeErrorMessage();
|
||||
setWebSocketStatus("DISCONNECTED");
|
||||
return () => undefined; // conversation intentionally stopped
|
||||
@@ -312,6 +348,10 @@ export function WsClientProvider({
|
||||
!conversation.runtime_status ||
|
||||
conversation.runtime_status === "STATUS$STOPPED"
|
||||
) {
|
||||
if (sioRef.current) {
|
||||
sioRef.current.disconnect();
|
||||
}
|
||||
sioRef.current = null;
|
||||
return () => undefined; // conversation not ready for WebSocket connection
|
||||
}
|
||||
|
||||
@@ -360,6 +400,7 @@ export function WsClientProvider({
|
||||
sio.on("disconnect", handleDisconnect);
|
||||
|
||||
sioRef.current = sio;
|
||||
flushPendingEvents(sio);
|
||||
|
||||
return () => {
|
||||
sio.off("connect", handleConnect);
|
||||
|
||||
@@ -8,17 +8,18 @@
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
import React, { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import posthog from "posthog-js";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import "./i18n";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import OptionService from "./api/option-service/option-service.api";
|
||||
import { displayErrorToast } from "./utils/custom-toast-handlers";
|
||||
import { queryClient } from "./query-client-config";
|
||||
|
||||
function PosthogInit() {
|
||||
function PostHogWrapper({ children }: { children: React.ReactNode }) {
|
||||
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
@@ -27,20 +28,27 @@ function PosthogInit() {
|
||||
setPosthogClientKey(config.POSTHOG_CLIENT_KEY);
|
||||
} catch {
|
||||
displayErrorToast("Error fetching PostHog client key");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (posthogClientKey) {
|
||||
posthog.init(posthogClientKey, {
|
||||
if (isLoading || !posthogClientKey) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<PostHogProvider
|
||||
apiKey={posthogClientKey}
|
||||
options={{
|
||||
api_host: "https://us.i.posthog.com",
|
||||
person_profiles: "identified_only",
|
||||
});
|
||||
}
|
||||
}, [posthogClientKey]);
|
||||
|
||||
return null;
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function prepareApp() {
|
||||
@@ -62,10 +70,10 @@ prepareApp().then(() =>
|
||||
document,
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<PostHogWrapper>
|
||||
<HydratedRouter />
|
||||
</PostHogWrapper>
|
||||
</QueryClientProvider>
|
||||
<div id="modal-portal-exit" />
|
||||
</StrictMode>,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export const useAddGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackGitProviderConnected } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -11,7 +13,18 @@ export const useAddGitProviders = () => {
|
||||
}: {
|
||||
providers: Record<Provider, ProviderToken>;
|
||||
}) => SecretsService.addGitProvider(providers),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_, { providers }) => {
|
||||
// Track which providers were connected (filter out empty tokens)
|
||||
const connectedProviders = Object.entries(providers)
|
||||
.filter(([, value]) => value.token && value.token.trim() !== "")
|
||||
.map(([key]) => key);
|
||||
|
||||
if (connectedProviders.length > 0) {
|
||||
trackGitProviderConnected({
|
||||
providers: connectedProviders,
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
|
||||
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
@@ -17,6 +17,8 @@ interface CreateConversationVariables {
|
||||
suggestedTask?: SuggestedTask;
|
||||
conversationInstructions?: string;
|
||||
createMicroagent?: CreateMicroagent;
|
||||
parentConversationId?: string;
|
||||
agentType?: "default" | "plan";
|
||||
}
|
||||
|
||||
// Response type that combines both V1 and legacy responses
|
||||
@@ -31,6 +33,7 @@ interface CreateConversationResponse extends Partial<Conversation> {
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackConversationCreated } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
@@ -43,6 +46,8 @@ export const useCreateConversation = () => {
|
||||
suggestedTask,
|
||||
conversationInstructions,
|
||||
createMicroagent,
|
||||
parentConversationId,
|
||||
agentType,
|
||||
} = variables;
|
||||
|
||||
const useV1 = USE_V1_CONVERSATION_API() && !createMicroagent;
|
||||
@@ -56,6 +61,8 @@ export const useCreateConversation = () => {
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
undefined, // trigger - will be set by backend
|
||||
parentConversationId,
|
||||
agentType,
|
||||
);
|
||||
|
||||
// Return a special task ID that the frontend will recognize
|
||||
@@ -86,12 +93,11 @@ export const useCreateConversation = () => {
|
||||
is_v1: false,
|
||||
};
|
||||
},
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: query?.length,
|
||||
has_repository: !!repository,
|
||||
onSuccess: async (_, { repository }) => {
|
||||
trackConversationCreated({
|
||||
hasRepository: !!repository,
|
||||
});
|
||||
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import AuthService from "#/api/auth-service/auth-service.api";
|
||||
import { useConfig } from "../query/use-config";
|
||||
import { clearLoginData } from "#/utils/local-storage";
|
||||
|
||||
export const useLogout = () => {
|
||||
const posthog = usePostHog();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import { PostSettings } from "#/types/settings";
|
||||
@@ -41,6 +41,7 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
};
|
||||
|
||||
export const useSaveSettings = () => {
|
||||
const posthog = usePostHog();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: currentSettings } = useSettings();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ConversationService from "#/api/conversation-service/conversation-service
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
export interface BatchFeedbackData {
|
||||
exists: boolean;
|
||||
@@ -25,13 +26,20 @@ export const getFeedbackExistsQueryKey = (
|
||||
export const useBatchFeedback = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const queryClient = useQueryClient();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: getFeedbackQueryKey(conversationId),
|
||||
queryFn: () => ConversationService.getBatchFeedback(conversationId!),
|
||||
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
!isV1Conversation,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
|
||||
|
||||
export type FeedbackData = BatchFeedbackData;
|
||||
@@ -9,6 +10,9 @@ export const useFeedbackExists = (eventId?: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
return useQuery<FeedbackData>({
|
||||
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
|
||||
@@ -22,7 +26,11 @@ export const useFeedbackExists = (eventId?: number) => {
|
||||
|
||||
return batchData?.[eventId.toString()] ?? { exists: false };
|
||||
},
|
||||
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
|
||||
enabled:
|
||||
!!eventId &&
|
||||
!!conversationId &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
!isV1Conversation,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
import { useConfig } from "./use-config";
|
||||
import UserService from "#/api/user-service/user-service.api";
|
||||
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
|
||||
|
||||
export const useGitUser = () => {
|
||||
const posthog = usePostHog();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// Use the shared hook to determine if we should fetch user data
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import posthog from "posthog-js";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import { DEFAULT_SETTINGS } from "#/services/settings";
|
||||
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
|
||||
@@ -61,12 +59,6 @@ export const useSettings = () => {
|
||||
},
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.isFetched && query.data?.LLM_API_KEY_SET) {
|
||||
posthog.capture("user_activated");
|
||||
}
|
||||
}, [query.data?.LLM_API_KEY_SET, query.isFetched]);
|
||||
|
||||
// We want to return the defaults if the settings aren't found so the user can still see the
|
||||
// options to make their initial save. We don't set the defaults in `initialData` above because
|
||||
// that would prepopulate the data to the cache and mess with expectations. Read more:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user