Compare commits

..

75 Commits

Author SHA1 Message Date
Graham Neubig
2e2ed78ee2 Merge branch 'main' into feature/org-microagents-blocking-timeout 2025-08-05 16:20:08 -04:00
chuckbutkus
cda29107f1 Update user and group creation in Dockerfile (#10096) 2025-08-05 14:38:53 -04:00
chuckbutkus
97bfa96a15 Enterprise sso (#10008)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-08-04 17:50:59 -04:00
Xingyao Wang
0e2f2f4173 Add global git configuration to Dockerfile.j2 (#10080)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-05 04:10:09 +08:00
Rohit Malhotra
5554b7b418 refactor: modify ExperimentManager to take config instead of agent_config and call before session creation (#10001)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 16:05:05 -04:00
Chase Farmer
d30f77c60a Honor user-set flag for LOG_TO_FILE (#10078) 2025-08-04 19:20:20 +00:00
aeft
a36d1673fa feat(cli): add agent state validation to /resume command (#10066) 2025-08-04 21:14:21 +02:00
mamoodi
d233e89873 Fix Tavily search API key placeholder format (#10075)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 15:13:29 -04:00
Yumi Izumi
402b6224a6 feat: allow optional HTTP protocol for self-hosted GitLab instances (#9757)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-08-04 14:54:19 -04:00
aeft
4e5e2a7095 docs: fix typos and update section numbering in Development.md (#10067) 2025-08-04 14:50:00 -04:00
Tim O'Farrell
a0adbd741a Fix: Display logout option even when user is not available (#10077)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 11:24:20 -06:00
llamantino
d5cdecea21 fix(docker-runtime): adjust default port ranges to avoid Windows ephemeral ports (#9924) 2025-08-04 09:30:18 -04:00
Xingyao Wang
fef287fcb0 Always install Docker with MTU 1450 configuration (#10007)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 21:21:03 +08:00
Ryan H. Tran
6fc1a63eb8 [CLI] Add default fetch MCP server & update doc to require uvx (#9952) 2025-08-04 04:30:16 +00:00
aeft
5364e2638b docs: update CodeAct agent step method Returns documentation (#10054) 2025-08-02 19:33:44 +00:00
Graham Neubig
d3983b00bd [Feature Request]: Make git username and email configurable (#9942)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-02 05:20:05 +08:00
Calvin Smith
39fff41dd4 Set default condenser to ConversationWindowCondenser (#10031)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-01 10:35:40 -06:00
Bashwara Undupitiya
d0a8c896c2 feature: Add Jira, Jira DC and Linear UI Integrations (#9761)
Co-authored-by: Wishmi Dhanapala <wishmis@verdentra.com>
2025-08-01 10:25:49 -05:00
dependabot[bot]
4f24bcaec9 chore(deps): bump the version-all group in /frontend with 7 updates (#10042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 15:23:19 +00:00
Tim O'Farrell
d3209f8c28 Add unit tests for LocalRuntime's vscode_url and web_hosts methods (#9984)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-01 07:02:28 -06:00
Rohit Malhotra
287c34b3f3 Add branch information to repository context to prevent unwanted branch switching (#9833) 2025-08-01 00:25:36 -04:00
Rohit Malhotra
1cdc38eafb Add LLM disclaimer to Slack integration documentation (#10006)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 18:43:08 -04:00
Rohit Malhotra
ae045251f2 Revert "Add experiment for agent config" (#10032) 2025-07-31 21:25:44 +00:00
Tim O'Farrell
9b374cd6b8 Fix for issued due to changes in spec for custom secrets (#10028) 2025-07-31 14:49:56 -06:00
mamoodi
4759a78c12 Patch release 0.51.1 (#10023)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 15:21:54 -04:00
greese-insight
d88e68eb49 fix: update openhands local runtime to handle provider tokens correctly (#9915)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-31 15:21:33 -04:00
Tim O'Farrell
b9abdf10ce Fixes for git diff viewer (#10026) 2025-07-31 15:19:56 -04:00
Denys Vitali
5b5a9718c2 fix(runtime): use async in git clone (#9334)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-07-31 13:59:20 -04:00
Robert Brennan
86dac5efe4 Improve connecting status messages with time expectations (#10016)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 15:20:33 +00:00
Ivan Dagelic
dfeab9f767 chore: env for installing third party providers (#9767)
Signed-off-by: Ivan Dagelic <dagelic.ivan@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-07-31 15:20:06 +00:00
dependabot[bot]
4b13658401 chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.81.2 to 5.83.1 in /frontend in the eslint group (#10019)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-31 15:14:13 +00:00
Carlos Freund
844b00a380 Make backend and frontend ports configurable in Makefile (#9722) 2025-07-31 11:11:43 -04:00
Carlos Freund
29fe911828 fix(conf): add cause to re-raised value-error to keep context. (#9940)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyaoww@gmail.com>
2025-07-31 22:59:13 +08:00
Xingyao Wang
5282770a4c Fix litellm_proxy model info JSON parsing error handling (#10009)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 14:52:36 +00:00
Hiep Le
953902dcce feat(frontend): integrate with the updated get microagents API for the microagent management page. (#10010) 2025-07-31 18:42:07 +04:00
sp.wack
b28e0533e0 fix(feedback): Batch event feedback status request (#9884)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 18:07:06 +04:00
mamoodi
43555fa13b Release 0.51.0 (#9993) 2025-07-31 09:55:05 -04:00
Hiep Le
10ae481b91 refactor: improve the get microagents API (#9958)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-31 00:33:02 -04:00
Xingyao Wang
c2e860fe92 Improve LLM call metadata (#10004)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 07:02:49 +08:00
Xingyao Wang
c2fc84e6ea Remove task completion status message from finish action display (#9977)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-31 04:33:45 +08:00
Xingyao Wang
6f44b7352e Add search API key settings to CLI (#9976)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 02:03:29 +08:00
dependabot[bot]
16106e6262 chore(deps): bump the version-all group in /frontend with 3 updates (#9997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-30 15:20:33 +00:00
Xingyao Wang
6cea73b6da Add qwen-3-coder-480b to OpenHands provider (#9985)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-30 23:12:31 +08:00
llamantino
fdf9a49e28 feat(frontend): improve conversation card context menu (#9917) 2025-07-30 19:09:56 +04:00
Erkin Alp Güney
e348634dbd Fix user input commands being echoed twice in terminal (#9959)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-30 17:47:21 +04:00
Ryan H. Tran
b67db15f8a [CLI] Fix errno 21 warning when reading directory (#9990) 2025-07-30 21:38:45 +08:00
Engel Nyst
a32a623078 perf(gemini): Apply Gemini 2.5 Pro performance optimizations from PR 9913 (#9925)
Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-29 23:28:50 +00:00
Rohit Malhotra
03c8312f5f Add maintenance banner feature (#9981)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-29 17:35:10 -04:00
Graham Neubig
b75a61bce9 Fix make lint dependencies to work out of the box (#9983)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-29 21:14:00 +00:00
Tim O'Farrell
2c36e2447c Fix for app/worker urls (#9980) 2025-07-29 14:49:22 -06:00
Graham Neubig
f87c827fe6 Improve OpenHands authentication error message (#9780)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-29 20:22:47 +00:00
Xingyao Wang
3f395e3cee feat: show export trajectory button in SaaS mode for debugging (#9979)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-30 03:26:21 +08:00
Xingyao Wang
7a45ebf0f4 Fix MCP config priority logic in sessions.py (#9237)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-29 18:47:19 +00:00
Rohit Malhotra
5b13cfc2a0 Add experiment for agent config (#9861)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-29 17:56:28 +00:00
Tim O'Farrell
5553584056 Fix git changes panel (#9967) 2025-07-29 11:21:49 -06:00
Rohit Malhotra
e951612ff4 Add IP whitelisting information for Bitbucket Cloud integration (#9894)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-29 11:54:54 -04:00
dependabot[bot]
426e16b17d chore(deps): bump the version-all group in /frontend with 7 updates (#9960)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-29 14:59:27 +00:00
Tim O'Farrell
d9a595c9b1 Replace bash scripts with Python for git operations (#9914)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-29 07:34:52 -06:00
Engel Nyst
8fb3728391 Do not override user's git config in CLI mode or local machine (#9905) 2025-07-28 20:12:28 +02:00
dependabot[bot]
d4c94dce83 chore(deps): bump the version-all group in /frontend with 7 updates (#9947)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-28 17:16:13 +00:00
Rohit Malhotra
74d6633e9b Update Slack OAuth URL for the 'Install OpenHands Slack App' button (#9908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 17:08:25 +00:00
Mislav Lukach
eecad803b1 feat(ds): avoid building tailwind (#9945) 2025-07-28 21:04:19 +04:00
Rohit Malhotra
da7a31a6fa Update Slack integration 'Add to Slack' button link (#9906)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 12:43:01 -04:00
C9luster
c677f7284e Fix the BUG in the __init__ file of openhands to obtain the version (#9840)
Co-authored-by: yinjiaqi <yinjiaqi@baidu.com>
2025-07-28 16:13:21 +00:00
sp.wack
60e8e55311 fix: keep tabs visible when agent is stopped (#9941)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 22:01:54 +08:00
Xingyao Wang
18557e8654 fix: Properly handle AgentRuntimeTimeoutError in runtime base (#9923)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 13:33:19 +00:00
llamantino
39c67e2b92 fix(ci): fix fe unit tests workflow failure due to invalid node-version value (#9928) 2025-07-28 12:13:10 +00:00
Carlos Freund
b5146e3188 fix: use poetry run for pre-commit in husky hook (#9934)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 16:08:29 +04:00
Erkin Alp Güney
a59a6f3041 Optimize pre commit hooks (#9939)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-28 16:07:22 +04:00
llamantino
056d3e4933 fix(tests): fix tests missed by failing frontend test workflow and other flaky tests (#9943) 2025-07-28 16:00:14 +04:00
Engel Nyst
2b4a5a73a4 Fix configuration precedence in CLI mode (#9911)
Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-27 22:42:22 +02:00
Carlos Freund
46504ab0da Fix deprecation message to reference SANDBOX_VOLUMES instead of non-existent RUNTIME_MOUNT (#9931)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-27 18:36:12 +02:00
Ray Myers
412f6ce58d chore - remove stripe and minio python dependencies (#9921) 2025-07-27 10:26:18 -05:00
Xingyao Wang
c8f9e6b9fc feat(llm) : add qwen to fn call supported model (#9929) 2025-07-27 04:53:55 +00:00
openhands
5ac1fd9077 Make organization microagents clone operation blocking with 60-second timeout 2025-06-23 22:32:06 +00:00
221 changed files with 9100 additions and 5312 deletions

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: 22
node-version: [22]
fail-fast: true
steps:
- name: Checkout

View File

@@ -1,59 +1,158 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
echo "This hook runs selective linting based on changed files."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Get the list of staged files
STAGED_FILES=$(git diff --cached --name-only)
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
# Check if any files match specific patterns
has_frontend_changes=false
has_backend_changes=false
has_vscode_changes=false
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
# Check each file individually to avoid issues with grep
for file in $STAGED_FILES; do
if [[ $file == frontend/* ]]; then
has_frontend_changes=true
elif [[ $file == openhands/* || $file == evaluation/* || $file == tests/* ]]; then
has_backend_changes=true
# Check for VSCode extension changes (subset of backend changes)
if [[ $file == openhands/integrations/vscode/* ]]; then
has_vscode_changes=true
fi
fi
done
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
echo "Analyzing changes..."
echo "- Frontend changes: $has_frontend_changes"
echo "- Backend changes: $has_backend_changes"
echo "- VSCode extension changes: $has_vscode_changes"
# Return to the original directory
cd ..
if [ $EXIT_CODE -eq 0 ]; then
echo "Frontend checks passed!"
fi
# Run frontend linting if needed
if [ "$has_frontend_changes" = true ]; then
# Check if we're in a CI environment or if frontend dependencies are missing
if [ -n "$CI" ] || ! command -v react-router &> /dev/null || ! command -v vitest &> /dev/null; then
echo "Skipping frontend checks (CI environment or missing dependencies detected)."
echo "WARNING: Frontend files have changed but frontend checks are being skipped."
echo "Please run 'make lint-frontend' manually before submitting your PR."
else
echo "Frontend directory not found. Skipping frontend checks."
echo "Running frontend linting..."
make lint-frontend
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Frontend linting checks passed!"
fi
# Run additional frontend checks
if [ -d "frontend" ]; then
echo "Running additional frontend checks..."
cd frontend || exit 1
# Run build
echo "Running npm build..."
npm run build
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
cd ..
fi
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
echo "Skipping frontend checks (no frontend changes detected)."
fi
# Run backend linting if needed
if [ "$has_backend_changes" = true ]; then
echo "Running backend linting..."
make lint-backend
if [ $? -ne 0 ]; then
echo "Backend linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Backend linting checks passed!"
fi
else
echo "Skipping backend checks (no backend changes detected)."
fi
# Run VSCode extension checks if needed
if [ "$has_vscode_changes" = true ]; then
# Check if we're in a CI environment
if [ -n "$CI" ]; then
echo "Skipping VSCode extension checks (CI environment detected)."
echo "WARNING: VSCode extension files have changed but checks are being skipped."
echo "Please run VSCode extension checks manually before submitting your PR."
else
echo "Running VSCode extension checks..."
if [ -d "openhands/integrations/vscode" ]; then
cd openhands/integrations/vscode || exit 1
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "VSCode extension linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension linting passed!"
fi
echo "Running npm typecheck..."
npm run typecheck
if [ $? -ne 0 ]; then
echo "VSCode extension type checking failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension type checking passed!"
fi
echo "Running npm compile..."
npm run compile
if [ $? -ne 0 ]; then
echo "VSCode extension compilation failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "VSCode extension compilation passed!"
fi
cd ../../..
fi
fi
else
echo "Skipping VSCode extension checks (no VSCode extension changes detected)."
fi
# If no specific code changes detected, run basic checks
if [ "$has_frontend_changes" = false ] && [ "$has_backend_changes" = false ]; then
echo "No specific code changes detected. Running basic checks..."
if [ -n "$STAGED_FILES" ]; then
# Run only basic pre-commit hooks for non-code files
poetry run pre-commit run --files $(echo "$STAGED_FILES" | tr '\n' ' ') --hook-stage commit --config ./dev_config/python/.pre-commit-config.yaml
if [ $? -ne 0 ]; then
echo "Basic checks failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Basic checks passed!"
fi
else
echo "No files changed. Skipping basic checks."
fi
fi
# Run any existing pre-commit hooks that might have been installed by the user

View File

@@ -34,7 +34,7 @@ _Dev Container: Reopen in Container_ command from the Command Palette
#### Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
```bash
@@ -71,7 +71,7 @@ This command will prompt you to enter the LLM API key, model name, and other var
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
@@ -154,12 +154,12 @@ poetry run pytest ./tests/unit/test_*.py
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
### 9. Use existing Docker image
### 10. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.50-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.51-nikolaik`
## Develop inside Docker container

View File

@@ -3,10 +3,10 @@ SHELL=/usr/bin/env bash
# Variables
BACKEND_HOST ?= "127.0.0.1"
BACKEND_PORT = 3000
BACKEND_PORT ?= 3000
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
FRONTEND_HOST ?= "127.0.0.1"
FRONTEND_PORT = 3001
FRONTEND_PORT ?= 3001
DEFAULT_WORKSPACE_DIR = "./workspace"
DEFAULT_MODEL = "gpt-4o"
CONFIG_FILE = config.toml
@@ -174,7 +174,7 @@ install-python-dependencies:
fi
@echo "$(GREEN)Python dependencies installed successfully.$(RESET)"
install-frontend-dependencies:
install-frontend-dependencies: check-npm check-nodejs
@echo "$(YELLOW)Setting up frontend environment...$(RESET)"
@echo "$(YELLOW)Detect Node.js version...$(RESET)"
@cd frontend && node ./scripts/detect-node-version.js
@@ -182,17 +182,17 @@ install-frontend-dependencies:
@cd frontend && npm install
@echo "$(GREEN)Frontend dependencies installed successfully.$(RESET)"
install-pre-commit-hooks:
install-pre-commit-hooks: check-python check-poetry install-python-dependencies
@echo "$(YELLOW)Installing pre-commit hooks...$(RESET)"
@git config --unset-all core.hooksPath || true
@poetry run pre-commit install --config $(PRE_COMMIT_CONFIG_PATH)
@echo "$(GREEN)Pre-commit hooks installed successfully.$(RESET)"
lint-backend:
lint-backend: install-pre-commit-hooks
@echo "$(YELLOW)Running linters...$(RESET)"
@poetry run pre-commit run --all-files --show-diff-on-failure --config $(PRE_COMMIT_CONFIG_PATH)
lint-frontend:
lint-frontend: install-frontend-dependencies
@echo "$(YELLOW)Running linters for frontend...$(RESET)"
@cd frontend && npm run lint

View File

@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。

View File

@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

View File

@@ -58,8 +58,8 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
# Default is 60000, but we've seen up to 200000
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
RUN groupadd app
RUN useradd -l -m -u $OPENHANDS_USER_ID -s /bin/bash openhands && \
RUN groupadd --gid $OPENHANDS_USER_ID app
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
usermod -aG app openhands && \
usermod -aG sudo openhands && \
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

View File

@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
unset WORKSPACE_BASE
fi
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
echo "Downloading and installing third_party_runtimes..."
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
echo "third_party_runtimes installed successfully."
else
echo "Failed to install third_party_runtimes." >&2
exit 1
fi
fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false

View File

@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.50-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.51-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -37,7 +37,16 @@
"usage/cloud/bitbucket-installation",
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation",
"usage/cloud/slack-installation"
"usage/cloud/slack-installation",
{
"group": "Project Management Tools",
"pages": [
"usage/cloud/project-management/overview",
"usage/cloud/project-management/jira-integration",
"usage/cloud/project-management/jira-dc-integration",
"usage/cloud/project-management/linear-integration"
]
}
]
},
"usage/cloud/cloud-ui",

BIN
docs/static/img/workspace-admin-edit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/static/img/workspace-configure.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/static/img/workspace-link.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/static/img/workspace-user-edit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -8,6 +8,29 @@ description: This guide walks you through the process of installing OpenHands Cl
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## IP Whitelisting
If your Bitbucket Cloud instance has IP restrictions, you'll need to whitelist the following IP addresses to allow OpenHands to access your repositories:
### Core App IP
```
34.68.58.200
```
### Runtime IPs
```
34.10.175.217
34.136.162.246
34.45.0.142
34.28.69.126
35.224.240.213
34.70.174.52
34.42.4.87
35.222.133.153
34.29.175.97
34.60.55.59
```
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.

View File

@@ -0,0 +1,118 @@
---
title: Jira Data Center Integration (Beta)
description: Complete guide for setting up Jira Data Center integration with OpenHands Cloud, including service account creation, personal access token generation, webhook configuration, and workspace integration setup.
---
# Jira Data Center Integration
## Platform Configuration
### Step 1: Create Service Account
1. **Access User Management**
- Log in to Jira Data Center as administrator
- Go to **Administration** > **User Management**
2. **Create User**
- Click **Create User**
- Username: `openhands-agent`
- Full Name: `OpenHands Agent`
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
- Password: Set a secure password
- Click **Create**
3. **Assign Permissions**
- Add user to appropriate groups
- Ensure access to relevant projects
- Grant necessary project permissions
### Step 2: Generate API Token
1. **Personal Access Tokens**
- Log in as the service account
- Go to **Profile** > **Personal Access Tokens**
- Click **Create token**
- Name: `OpenHands Cloud Integration`
- Expiry: Set appropriate expiration (recommend 1 year)
- Click **Create**
- **Important**: Copy and store the token securely
### Step 3: Configure Webhook
1. **Create Webhook**
- Go to **Administration** > **System** > **WebHooks**
- Click **Create a WebHook**
- **Name**: `OpenHands Cloud Integration`
- **URL**: `https://app.all-hands.dev/integration/jira-dc/events`
- Set a suitable webhook secret
- **Issue related events**: Select the following:
- Issue updated
- Comment created
- **JQL Filter**: Leave empty (or customize as needed)
- Click **Create**
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
---
## Workspace Integration
### Step 1: Log in to OpenHands Cloud
1. **Navigate and Authenticate**
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
### Step 2: Configure Jira Data Center Integration
1. **Access Integration Settings**
- Navigate to **Settings** > **Integrations**
- Locate **Jira Data Center** section
2. **Configure Workspace**
- Click **Configure** button
- Enter your workspace name and click **Connect**
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
- **Webhook Secret**: The webhook secret from Step 3 above
- **Service Account Email**: The service account email from Step 1 above
- **Service Account API Key**: The personal access token from Step 2 above
- Ensure **Active** toggle is enabled
3. **Complete OAuth Flow**
- You'll be redirected to Jira Data Center to complete OAuth verification
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
### Managing Your Integration
**Edit Configuration:**
- Click the **Edit** button next to your configured platform
- Update any necessary credentials or settings
- Click **Update** to apply changes
- You will need to repeat the OAuth flow as before
- **Important:** Only the original user who created the integration can see the edit view
**Unlink Workspace:**
- In the edit view, click **Unlink** next to the workspace name
- This will deactivate your workspace link
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
### Screenshots
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
</Accordion>
</AccordionGroup>

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
---
title: Project Management Tool Integrations
description: Overview of OpenHands Cloud integrations with project management platforms including Jira Cloud, Jira Data Center, and Linear. Learn about setup requirements, usage methods, and troubleshooting.
---
# Project Management Tool Integrations
## Overview
OpenHands Cloud integrates with project management platforms (Jira Cloud, Jira Data Center, and Linear) to enable AI-powered task delegation. Users can invoke the OpenHands agent by:
- Adding `@openhands` in ticket comments
- Adding the `openhands` label to tickets
## Prerequisites
Integration requires two levels of setup:
1. **Platform Configuration** - Administrative setup of service accounts and webhooks on your project management platform (see individual platform documentation below)
2. **Workspace Integration** - Self-service configuration through the OpenHands Cloud UI to link your OpenHands account to the target workspace
### Platform-Specific Setup Guides:
- [Jira Cloud Integration](./jira-integration.md)
- [Jira Data Center Integration](./jira-dc-integration.md)
- [Linear Integration](./linear-integration.md)
## Usage
Once both the platform configuration and workspace integration are completed, users can trigger the OpenHands agent within their project management platforms using two methods:
### Method 1: Comment Mention
Add a comment to any issue with `@openhands` followed by your task description:
```
@openhands Please implement the user authentication feature described in this ticket
```
### Method 2: Label-based Delegation
Add the label `openhands` to any issue. The OpenHands agent will automatically process the issue based on its description and requirements.
### Git Repository Detection
The OpenHands agent needs to identify which Git repository to work with when processing your issues. Here's how to ensure proper repository detection:
#### Specifying the Target Repository
**Required:** Include the target Git repository in your issue description or comment to ensure the agent works with the correct codebase.
**Supported Repository Formats:**
- Full HTTPS URL: `https://github.com/owner/repository.git`
- GitHub URL without .git: `https://github.com/owner/repository`
- Owner/repository format: `owner/repository`
#### Platform-Specific Behavior
**Linear Integration:** When GitHub integration is enabled for your Linear workspace with issue sync activated, the target repository is automatically detected from the linked GitHub issue. Manual specification is not required in this configuration.
**Jira Integrations:** Always include the repository information in your issue description or `@openhands` comment to ensure proper repository detection.
## Troubleshooting
### Platform Configuration Issues
- **Webhook not triggering**: Verify the webhook URL is correct and the proper event types are selected (Comment, Issue updated)
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted
- **Permission errors**: Ensure the service account has access to relevant projects/teams and appropriate permissions
### Workspace Integration Issues
- **Workspace linking requests credentials**: If there are no active workspace integrations for the workspace you specified, you need to configure it first. Contact your platform administrator that you want to integrate with (eg: Jira, Linear)
- **OAuth flow fails**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
- **Integration not found**: Verify the workspace name matches exactly and that platform configuration was completed first
### General Issues
- **Agent not responding**: Check webhook logs in your platform settings and verify service account status
- **Authentication errors**: Verify Git provider permissions and OpenHands Cloud access
- **Partial functionality**: Ensure both platform configuration and workspace integration are properly completed
### Getting Help
For additional support, contact OpenHands Cloud support with:
- Your integration platform (Linear, Jira Cloud, or Jira Data Center)
- Workspace name
- Error logs from webhook/integration attempts
- Screenshots of configuration settings (without sensitive credentials)

View File

@@ -12,6 +12,10 @@ description: This guide walks you through installing the OpenHands Slack app.
allowFullScreen>
</iframe>
<Info>
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
</Info>
## Prerequisites
- Access to OpenHands Cloud.
@@ -24,7 +28,7 @@ description: This guide walks you through installing the OpenHands Slack app.
**This step is for Slack admins/owners**
1. Make sure you have permissions to install Apps to your workspace.
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
2. Click the button below to install OpenHands Slack App <a target="_blank" href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack.png" srcSet="https://platform.slack-edge.com/img/add_to_slack.png 1x, https://platform.slack-edge.com/img/add_to_slack@2x.png 2x" /></a>
3. In the top right corner, select the workspace to install the OpenHands Slack app.
4. Review permissions and click allow.

View File

@@ -20,7 +20,7 @@ for scripting.
### Running with Python
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported)
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported) and `uvx` for the default `fetch` MCP server (more details below).
1. Install OpenHands using pip:
```bash
@@ -103,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -112,7 +112,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
python -m openhands.cli.main --override-cli-mode true
```
@@ -186,7 +186,7 @@ To configure Model Context Protocol (MCP) servers, you can refer to the document
This command modifies your `~/.openhands/config.toml` file and will prompt you to restart OpenHands for changes to take effect.
To enable the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) search engine, you can set the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
By default, the [Fetch MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) will be automatically configured for OpenHands. You can also [enable search engine](../search-engine-setup) via the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) by setting the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
##### Example of the `config.toml` file with MCP server configuration:

View File

@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.50
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.51
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None

View File

@@ -30,5 +30,6 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
## Pricing
Pricing follows official API provider rates.
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.

View File

@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.

View File

@@ -10,6 +10,7 @@ Model Context Protocol (MCP) is a mechanism that allows OpenHands to communicate
servers can provide additional functionality to the agent, such as specialized data processing, external API access,
or custom tools. MCP is based on the open standard defined at [modelcontextprotocol.io](https://modelcontextprotocol.io).
<Note>
MCP is currently not available on OpenHands Cloud. This feature is only available when running OpenHands locally.
</Note>
@@ -35,41 +36,57 @@ MCP configuration can be defined in:
* The OpenHands UI through the Settings under the `MCP` tab.
* The `config.toml` file under the `[mcp]` section if not using the UI.
### Configuration Example via config.toml
### Configuration Examples
#### Recommended: Using Proxy Servers (SSE/HTTP)
For stdio-based MCP servers, we recommend using MCP proxy tools like [`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to HTTP/SSE endpoints:
Start the proxy servers separately:
```bash
# Terminal 1: Filesystem server proxy
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
# Terminal 2: Fetch server proxy
supergateway --stdio "uvx mcp-server-fetch" --port 8081
```
Then configure OpenHands to use the HTTP endpoint:
```toml
[mcp]
# SSE Servers - External servers that communicate via Server-Sent Events
# SSE Servers - Recommended approach using proxy tools
sse_servers = [
# Basic SSE server with just a URL
"http://example.com:8080/mcp",
# SSE server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
# SuperGateway proxy for fetch server
"http://localhost:8081/sse",
# External MCP service with authentication
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
]
```
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# Stdio Servers - Local processes that communicate via standard input/output
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
```toml
[mcp]
# Direct stdio servers - use only for development/testing
stdio_servers = [
# Basic stdio server
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
# Stdio server with environment variables
{
name="data-processor",
command="python",
args=["-m", "my_mcp_server"],
name="filesystem",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/"],
env={
"DEBUG": "true",
"PORT": "8080"
"DEBUG": "true"
}
}
]
@@ -103,6 +120,8 @@ SHTTP (Streamable HTTP) servers are configured using either a string URL or an o
### Stdio Servers
**Note**: While stdio servers are supported, we recommend using MCP proxies (see above) for better reliability and performance.
Stdio servers are configured using an object with the following properties:
- `name` (required)
@@ -123,6 +142,39 @@ Stdio servers are configured using an object with the following properties:
- Default: `{}`
- Description: Environment variables to set for the server process
#### When to Use Direct Stdio
Direct stdio connections may still be appropriate in these scenarios:
- **Development and testing**: Quick prototyping of MCP servers
- **Simple, single-use tools**: Tools that don't require high reliability or concurrent access
- **Local-only environments**: When you don't want to manage additional proxy processes
For production use, we recommend using proxy tools like SuperGateway.
### Other Proxy Tools
Other options include:
- **Custom FastAPI/Express servers**: Build your own HTTP wrapper around stdio MCP servers
- **Docker-based proxies**: Containerized solutions for better isolation
- **Cloud-hosted MCP services**: Third-party services that provide MCP endpoints
### Troubleshooting MCP Connections
#### Common Issues with Stdio Servers
- **Process crashes**: Stdio processes may crash without proper error handling
- **Deadlocks**: Stdio communication can deadlock under high load
- **Resource leaks**: Zombie processes if not properly managed
- **Debugging difficulty**: Hard to inspect stdio communication
#### Benefits of Using Proxies
- **HTTP status codes**: Clear error reporting via standard HTTP responses
- **Request logging**: Easy to log and monitor HTTP requests
- **Load balancing**: Can distribute requests across multiple server instances
- **Health checks**: HTTP endpoints can provide health status
- **CORS support**: Better integration with web-based tools
## Transport Protocols
OpenHands supports three different MCP transport protocols:

View File

@@ -8,4 +8,4 @@ npx lint-staged
# Run backend pre-commit
echo "Running backend pre-commit..."
cd ..
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
poetry run pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml

View File

@@ -120,6 +120,9 @@ describe("ExpandableMessage", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const RouterStub = createRoutesStub([

View File

@@ -28,7 +28,6 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},
@@ -114,7 +113,6 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},

View File

@@ -72,6 +72,7 @@ describe("HomeHeader", () => {
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId

View File

@@ -209,6 +209,7 @@ describe("RepoConnector", () => {
undefined,
"main",
undefined,
undefined,
);
});

View File

@@ -97,6 +97,7 @@ describe("TaskCard", () => {
},
undefined,
undefined,
undefined,
);
});
});

View File

@@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string, options?: { time?: string }) => {
const translations: Record<string, string> = {
"MAINTENANCE$SCHEDULED_MESSAGE": `Scheduled maintenance will begin at ${options?.time || "{{time}}"}`,
};
return translations[key] || key;
},
}),
};
});
describe("MaintenanceBanner", () => {
it("renders maintenance banner with formatted time", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const { container } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
// Check if the warning icon (SVG) is present
const svgIcon = container.querySelector('svg');
expect(svgIcon).toBeInTheDocument();
});
it("handles invalid date gracefully", () => {
const invalidTime = "invalid-date";
render(<MaintenanceBanner startTime={invalidTime} />);
// Should still render the banner with the original string
expect(screen.getByText(/Scheduled maintenance will begin at invalid-date/)).toBeInTheDocument();
});
it("formats ISO date string correctly", () => {
const isoTime = "2024-01-15T15:30:00.000Z";
render(<MaintenanceBanner startTime={isoTime} />);
// Should render the banner (exact time format will depend on user's timezone)
expect(screen.getByText(/Scheduled maintenance will begin at/)).toBeInTheDocument();
});
});

View File

@@ -105,22 +105,12 @@ describe("MicroagentManagement", () => {
const mockMicroagents: RepositoryMicroagent[] = [
{
name: "test-microagent-1",
type: "repo",
content: "Test microagent content 1",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-1",
},
{
name: "test-microagent-2",
type: "knowledge",
content: "Test microagent content 2",
triggers: ["knowledge", "test"],
inputs: [],
tools: [],
created_at: "2021-10-02T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-2",
@@ -173,6 +163,13 @@ describe("MicroagentManagement", () => {
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
...mockConversations,
]);
// Setup default mock for getRepositoryMicroagentContent
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: ["test", "update"],
});
});
it("should render the microagent management page", async () => {
@@ -1187,17 +1184,6 @@ describe("MicroagentManagement", () => {
expect(conversation1).toBeInTheDocument();
expect(conversation2).toBeInTheDocument();
// Check that created dates are displayed for conversations (there are multiple elements with the same text)
const createdDates = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/01\/2021/,
);
expect(createdDates.length).toBeGreaterThan(0);
const createdDates2 = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/02\/2021/,
);
expect(createdDates2.length).toBeGreaterThan(0);
});
it("should handle multiple repository expansions with conversations", async () => {
@@ -1475,11 +1461,6 @@ describe("MicroagentManagement", () => {
describe("MicroagentManagementMain", () => {
const mockRepositoryMicroagent: RepositoryMicroagent = {
name: "test-microagent",
type: "repo",
content: "Test microagent content",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent",
@@ -1820,11 +1801,6 @@ describe("MicroagentManagement", () => {
it("should handle microagent with all required properties", async () => {
const completeMicroagent: RepositoryMicroagent = {
name: "complete-microagent",
type: "knowledge",
content: "Complete microagent content with all properties",
triggers: ["complete", "test"],
inputs: ["input1", "input2"],
tools: ["tool1", "tool2"],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/complete-microagent",
@@ -1874,11 +1850,6 @@ describe("MicroagentManagement", () => {
describe("Update microagent functionality", () => {
const mockMicroagentForUpdate: RepositoryMicroagent = {
name: "update-test-microagent",
type: "repo",
content: "Original microagent content for testing updates",
triggers: ["original", "test"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/update-test-microagent",
@@ -1999,11 +1970,13 @@ describe("MicroagentManagement", () => {
},
});
// Check that the form fields are populated with existing data
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Original microagent content for testing updates",
);
// Wait for the content to be loaded and form fields to be populated
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Original microagent content for testing updates",
);
});
});
it("should handle update microagent form submission", async () => {
@@ -2207,12 +2180,16 @@ describe("MicroagentManagement", () => {
it("should handle update modal with microagent that has no content", async () => {
const user = userEvent.setup();
const microagentWithoutContent = {
...mockMicroagentForUpdate,
content: "",
};
// Render with update modal visible and microagent without content
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2222,7 +2199,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutContent,
microagent: mockMicroagentForUpdate,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2243,19 +2220,25 @@ describe("MicroagentManagement", () => {
},
});
// Check that the form field is empty
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
// Wait for the content to be loaded and check that the form field is empty
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
});
});
it("should handle update modal with microagent that has no triggers", async () => {
const user = userEvent.setup();
const microagentWithoutTriggers = {
...mockMicroagentForUpdate,
triggers: [],
};
// Render with update modal visible and microagent without triggers
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2265,7 +2248,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutTriggers,
microagent: mockMicroagentForUpdate,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2397,11 +2380,6 @@ describe("MicroagentManagement", () => {
getRepositoryMicroagentsSpy.mockResolvedValue([
{
name: "test-microagent",
type: "repo",
content: "Test content",
triggers: [],
inputs: [],
tools: [],
created_at: "2021-10-01",
git_provider: "github",
path: ".openhands/microagents/test",
@@ -2486,11 +2464,6 @@ describe("MicroagentManagement", () => {
describe("Learn something new button functionality", () => {
const mockMicroagentForLearn: RepositoryMicroagent = {
name: "learn-test-microagent",
type: "repo",
content: "Test microagent content for learn functionality",
triggers: ["learn", "test"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/learn-test-microagent",
@@ -2586,6 +2559,14 @@ describe("MicroagentManagement", () => {
it("should populate form fields with current microagent data when learn button is clicked", async () => {
const user = userEvent.setup();
// Mock the content API to return the expected content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: ["learn", "test"],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
@@ -2626,21 +2607,27 @@ describe("MicroagentManagement", () => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that the form fields are populated with current microagent data
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Test microagent content for learn functionality",
);
// Wait for the content to be loaded and form to be populated
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Test microagent content for learn functionality",
);
});
});
it("should handle learn button click with microagent that has no content", async () => {
const user = userEvent.setup();
const microagentWithoutContent = {
...mockMicroagentForLearn,
content: "",
};
// Render with selected microagent without content
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2650,7 +2637,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutContent,
microagent: mockMicroagentForLearn,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2680,19 +2667,25 @@ describe("MicroagentManagement", () => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that the form field is empty
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
// Wait for the content to be loaded and check that the form field is empty
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
});
});
it("should handle learn button click with microagent that has no triggers", async () => {
const user = userEvent.setup();
const microagentWithoutTriggers = {
...mockMicroagentForLearn,
triggers: [],
};
// Render with selected microagent without triggers
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2702,7 +2695,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutTriggers,
microagent: mockMicroagentForLearn,
conversation: undefined,
},
addMicroagentModalVisible: false,

View File

@@ -28,6 +28,9 @@ describe("PaymentForm", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
});

View File

@@ -73,4 +73,73 @@ describe("TrajectoryActions", () => {
expect(onExportTrajectory).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should only render export button when isSaasMode is true", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const actions = screen.getByTestId("feedback-actions");
// Should not render feedback buttons in SaaS mode
expect(within(actions).queryByTestId("positive-feedback")).toBeNull();
expect(within(actions).queryByTestId("negative-feedback")).toBeNull();
// Should still render export button
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={false}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onExportTrajectory when export button is clicked in SaaS mode", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
});

View File

@@ -1,27 +1,64 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
// Create a mock for useIsAuthed that we can control per test
const useIsAuthedMock = vi
.fn()
.mockReturnValue({ data: true, isLoading: false });
// Mock the useIsAuthed hook
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
describe("UserActions", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
// Create a wrapper with QueryClientProvider
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
beforeEach(() => {
// Reset the mock to default value before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
});
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
vi.clearAllMocks();
});
it("should render", () => {
render(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -43,7 +80,7 @@ describe("UserActions", () => {
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -62,20 +99,25 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is undefined
// Context menu should NOT appear because user is not authenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
renderWithQueryClient(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -86,42 +128,74 @@ describe("UserActions", () => {
).toBeInTheDocument();
});
it("should NOT be able to access logout when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT be able to access logout when user is not authenticated", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should not be accessible because context menu doesn't appear
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should handle user prop changing from undefined to defined", () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Initially no user - context menu shouldn't work
// Context menu should NOT appear because user is not authenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
// Logout option should NOT be accessible when user is not authenticated
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
);
// Initially no user and not authenticated - menu should not appear
let userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
<QueryClientProvider client={queryClient}>
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</QueryClientProvider>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -135,16 +209,27 @@ describe("UserActions", () => {
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should disappear
rerender(<UserActions onLogout={onLogoutMock} />);
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<QueryClientProvider client={new QueryClient()}>
<UserActions onLogout={onLogoutMock} />
</QueryClientProvider>,
);
// Context menu should NOT be visible when user becomes unauthenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Logout option should not be accessible
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}

View File

@@ -1,140 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
// Mock the useConfig hook
vi.mock("#/hooks/query/use-config", () => ({
useConfig: vi.fn(),
}));
// Mock the useConversationId hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("useFeedbackExists", () => {
let queryClient: QueryClient;
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockCheckFeedbackExists.mockClear();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should not call API when APP_MODE is not saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "oss" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should call API when APP_MODE is saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
mockCheckFeedbackExists.mockResolvedValue({
exists: true,
rating: 5,
reason: "Great job!",
});
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for the query to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was called
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
"test-conversation-id",
123,
);
// Verify that the data is returned
expect(result.current.data).toEqual({
exists: true,
rating: 5,
reason: "Great job!",
});
});
it("should not call API when eventId is not provided", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(undefined), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should not call API when config is not loaded yet", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
});

View File

@@ -76,6 +76,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -111,6 +114,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -192,6 +198,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});

View File

@@ -17,6 +17,9 @@ const VALID_OSS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};
@@ -27,6 +30,9 @@ const VALID_SAAS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};

View File

@@ -222,10 +222,12 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the header button is clicked
await userEvent.click(headerLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
@@ -240,10 +242,12 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the repo button is clicked
await userEvent.click(repoLaunchButton);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
@@ -258,10 +262,12 @@ describe("HomeScreen", () => {
// All other buttons should be disabled when the task button is clicked
await userEvent.click(tasksLaunchButtons[0]);
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
await waitFor(() => {
expect(headerLaunchButton).toBeDisabled();
expect(repoLaunchButton).toBeDisabled();
tasksLaunchButtonsAfter.forEach((button) => {
expect(button).toBeDisabled();
});
});
});
});
@@ -327,6 +333,9 @@ describe("Settings 404", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const error = createAxiosNotFoundErrorObject();
@@ -349,6 +358,9 @@ describe("Setup Payment modal", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const error = createAxiosNotFoundErrorObject();

View File

@@ -366,17 +366,17 @@ describe("Form submission", () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-advanced");
await screen.findByTestId("llm-settings-form-advanced");
const submitButton = screen.getByTestId("submit-button");
const submitButton = await screen.findByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const model = await screen.findByTestId("llm-custom-model-input");
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
@@ -449,7 +449,7 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);

View File

@@ -101,7 +101,8 @@ describe("Content", () => {
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
// In SAAS mode, getSecrets is still called because the user is authenticated
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);

View File

@@ -86,6 +86,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -104,6 +107,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -122,6 +128,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +1,41 @@
{
"name": "openhands-frontend",
"version": "0.50.0",
"version": "0.51.1",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.1",
"@heroui/react": "^2.8.2",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.8.0",
"@stripe/stripe-js": "^7.6.1",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query": "^5.84.1",
"@vitejs/plugin-react": "^4.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.9",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"lucide-react": "^0.536.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"posthog-js": "^1.258.5",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.1",
@@ -51,7 +51,7 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.6",
"web-vitals": "^5.0.3",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
"scripts": {
@@ -84,17 +84,17 @@
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.1",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.7.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -102,7 +102,7 @@
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.2.3",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"cross-env": "^10.0.0",
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
@@ -119,9 +119,9 @@
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.3.0",
"stripe": "^18.4.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"

View File

@@ -14,12 +14,14 @@ import {
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
MicroagentContentResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -166,6 +168,38 @@ class OpenHands {
}
}
/**
* Get feedback for multiple events in a conversation
* @param conversationId The conversation ID
* @returns Map of event IDs to feedback data including existence, rating, reason and metadata
*/
static async getBatchFeedback(conversationId: string): Promise<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
> {
const url = `/feedback/conversation/${conversationId}/batch`;
const { data } = await openHands.get<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
>(url);
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
@@ -491,7 +525,7 @@ class OpenHands {
}
/**
* Get the available microagents for a specific repository
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
@@ -506,6 +540,27 @@ class OpenHands {
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,

View File

@@ -55,6 +55,12 @@ export interface GetConfigResponse {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
};
}
@@ -144,3 +150,10 @@ export interface CreateMicroagent {
git_provider?: Provider;
title?: string;
}
export interface MicroagentContentResponse {
content: string;
path: string;
git_provider: Provider;
triggers: string[];
}

View File

@@ -38,7 +38,7 @@ export function ActionSuggestions({
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Check your current branch name first - if it's main, master, deploy, or another common default branch name, create a new branch with a descriptive name related to your changes. Otherwise, use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. If you're on a default branch (e.g., main, master, deploy), create a new branch with a descriptive name otherwise use the current branch. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};

View File

@@ -232,17 +232,16 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
{config?.APP_MODE !== "saas" && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>
)}
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
isSaasMode={config?.APP_MODE === "saas"}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{curAgentState === AgentState.RUNNING && <TypingIndicator />}

View File

@@ -77,25 +77,8 @@ const getMcpActionContent = (event: MCPAction): string => {
const getThinkActionContent = (event: ThinkAction): string =>
event.args.thought;
const getFinishActionContent = (event: FinishAction): string => {
let content = event.args.final_thought;
switch (event.args.task_completed) {
case "success":
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_SUCCESSFULLY")}`;
break;
case "failure":
content += `\n\n\n${i18n.t("FINISH$TASK_NOT_COMPLETED")}`;
break;
case "partial":
default:
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_PARTIALLY")}`;
break;
}
return content.trim();
};
const getFinishActionContent = (event: FinishAction): string =>
event.args.final_thought.trim();
const getNoContentActionContent = (): string => "";
export const getActionContent = (event: OpenHandsAction): string => {

View File

@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
ref={ref}
className="absolute right-full md:left-full -top-1 z-10 w-fit"
>
<ContextMenuListItem onClick={onLogout}>
<ContextMenuListItem onClick={onLogout} data-testid="logout-button">
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</ContextMenu>

View File

@@ -0,0 +1,22 @@
import { cn } from "#/utils/utils";
interface ContextMenuIconTextProps {
icon: React.ComponentType<{ className?: string }>;
text: string;
className?: string;
iconClassName?: string;
}
export function ContextMenuIconText({
icon: Icon,
text,
className,
iconClassName,
}: ContextMenuIconTextProps) {
return (
<div className={cn("flex items-center gap-3 px-1", className)}>
<Icon className={cn("w-4 h-4 shrink-0", iconClassName)} />
{text}
</div>
);
}

View File

@@ -19,7 +19,7 @@ export function ContextMenuListItem({
onClick={onClick}
disabled={isDisabled}
className={cn(
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
"text-sm px-4 h-10 w-full text-start hover:bg-white/10 cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
)}
>

View File

@@ -18,7 +18,7 @@ export function ContextMenu({
<ul
data-testid={testId}
ref={ref}
className={cn("bg-tertiary rounded-md", className)}
className={cn("bg-tertiary rounded-md overflow-hidden", className)}
>
{children}
</ul>

View File

@@ -1,9 +1,20 @@
import {
Trash,
Power,
Pencil,
Download,
Wallet,
Wrench,
Bot,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuSeparator } from "../context-menu/context-menu-separator";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
interface ConversationCardContextMenuProps {
onClose: () => void;
@@ -31,6 +42,12 @@ export function ConversationCardContextMenu({
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const hasEdit = Boolean(onEdit);
const hasDownload = Boolean(onDownloadViaVSCode);
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
return (
<ContextMenu
ref={ref}
@@ -41,51 +58,84 @@ export function ConversationCardContextMenu({
position === "bottom" && "top-full",
)}
>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
{t(I18nKey.BUTTON$EDIT_TITLE)}
<ContextMenuIconText
icon={Pencil}
text={t(I18nKey.BUTTON$EDIT_TITLE)}
/>
</ContextMenuListItem>
)}
{hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
)}
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
<ContextMenuIconText
icon={Download}
text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
/>
</ContextMenuListItem>
)}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
{t(I18nKey.BUTTON$DISPLAY_COST)}
</ContextMenuListItem>
{hasDownload && (hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
)}
{onShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
<ContextMenuIconText
icon={Wrench}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
/>
</ContextMenuListItem>
)}
{onShowMicroagents && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
>
{t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
<ContextMenuIconText
icon={Bot}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
/>
</ContextMenuListItem>
)}
{hasTools && (hasInfo || hasControl) && <ContextMenuSeparator />}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
<ContextMenuIconText
icon={Wallet}
text={t(I18nKey.BUTTON$DISPLAY_COST)}
/>
</ContextMenuListItem>
)}
{hasInfo && hasControl && <ContextMenuSeparator />}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
</ContextMenuListItem>
)}
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
<ContextMenuIconText icon={Trash} text={t(I18nKey.BUTTON$DELETE)} />
</ContextMenuListItem>
)}
</ContextMenu>

View File

@@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { FaTriangleExclamation } from "react-icons/fa6";
interface MaintenanceBannerProps {
startTime: string;
}
export function MaintenanceBanner({ startTime }: MaintenanceBannerProps) {
const { t } = useTranslation();
// Convert EST timestamp to user's local timezone
const formatMaintenanceTime = (estTimeString: string): string => {
try {
// Parse the EST timestamp
// If the string doesn't include timezone info, assume it's EST
let dateToFormat: Date;
if (
estTimeString.includes("T") &&
(estTimeString.includes("-05:00") ||
estTimeString.includes("-04:00") ||
estTimeString.includes("EST") ||
estTimeString.includes("EDT"))
) {
// Already has timezone info
dateToFormat = new Date(estTimeString);
} else {
// Assume EST and convert to UTC for proper parsing
// EST is UTC-5, EDT is UTC-4, but we'll assume EST for simplicity
const estDate = new Date(estTimeString);
if (Number.isNaN(estDate.getTime())) {
throw new Error("Invalid date");
}
dateToFormat = estDate;
}
// Format to user's local timezone
return dateToFormat.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
} catch (error) {
// Fallback to original string if parsing fails
// eslint-disable-next-line no-console
console.warn("Failed to parse maintenance time:", error);
return estTimeString;
}
};
const localTime = formatMaintenanceTime(startTime);
return (
<div className="bg-primary text-[#0D0F11] p-4 rounded">
<div className="flex items-center">
<div className="flex-shrink-0">
<FaTriangleExclamation className="text-white align-middle" />
</div>
<div className="ml-3">
<p className="text-sm font-medium">
{t("MAINTENANCE$SCHEDULED_MESSAGE", { time: localTime })}
</p>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
@@ -38,22 +37,6 @@ export function MicroagentManagementMicroagentCard({
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
@@ -131,12 +114,9 @@ export function MicroagentManagementMicroagentCard({
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
{microagent.path}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
</div>
);

View File

@@ -8,11 +8,12 @@ import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { cn, extractRepositoryInfo } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
import {
BranchDropdown,
BranchLoadingState,
@@ -51,13 +52,23 @@ export function MicroagentManagementUpsertMicroagentModal({
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
// Extract owner and repo from full_name for content API
const { owner, repo, filePath } = extractRepositoryInfo(
selectedRepository,
microagent,
);
// Fetch microagent content when updating
const { data: microagentContentData, isLoading: isLoadingContent } =
useRepositoryMicroagentContent(owner, repo, filePath, true);
// Populate form fields with existing microagent data when updating
useEffect(() => {
if (isUpdate && microagent) {
setQuery(microagent.content);
setTriggers(microagent.triggers || []);
if (isUpdate && microagentContentData) {
setQuery(microagentContentData.content);
setTriggers(microagentContentData.triggers || []);
}
}, [isUpdate, microagent]);
}, [isUpdate, microagentContentData]);
const {
data: branches,
@@ -294,10 +305,11 @@ export function MicroagentManagementUpsertMicroagentModal({
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
isBranchesError ||
(isUpdate && isLoadingContent) // Disable while loading content for updates
}
>
{isLoading || isLoadingBranches
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>

View File

@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -7,8 +9,12 @@ import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
import { I18nKey } from "#/i18n/declaration";
import { extractRepositoryInfo } from "#/utils/utils";
export function MicroagentManagementViewMicroagentContent() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
@@ -19,55 +25,49 @@ export function MicroagentManagementViewMicroagentContent() {
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// Extract owner and repo from full_name (e.g., "owner/repo")
const { owner, repo, filePath } = extractRepositoryInfo(
selectedRepository,
microagent,
);
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
// Fetch microagent content using the new API
const {
data: microagentData,
isLoading,
error,
} = useRepositoryMicroagentContent(owner, repo, filePath, true);
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
{isLoading && (
<div className="flex items-center justify-center w-full h-full">
<Spinner size="lg" data-testid="loading-microagent-content-spinner" />
</div>
)}
{error && (
<div className="flex items-center justify-center w-full h-full">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT)}
</div>
)}
{microagentData && !isLoading && !error && (
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{microagentData.content}
</Markdown>
)}
</div>
);
}

View File

@@ -12,16 +12,21 @@ export function ConfigureGitHubRepositoriesAnchor({
const { t } = useTranslation();
return (
<a
data-testid="configure-github-repositories-button"
href={`https://github.com/apps/${slug}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="py-9"
>
<BrandButton type="button" variant="secondary">
<div data-testid="configure-github-repositories-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
`https://github.com/apps/${slug}/installations/new`,
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -6,16 +6,21 @@ export function InstallSlackAppAnchor() {
const { t } = useTranslation();
return (
<a
data-testid="install-slack-app-button"
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,chat:write,users:read,channels:history,groups:history,mpim:history,im:history&user_scope=channels:history,groups:history,im:history,mpim:history"
target="_blank"
rel="noreferrer noopener"
className="py-9"
>
<BrandButton type="button" variant="secondary">
<div data-testid="install-slack-app-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
"https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope=",
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.SLACK$INSTALL_APP)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -0,0 +1,440 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { useValidateIntegration } from "#/hooks/mutation/use-validate-integration";
interface ConfigureButtonProps {
onClick: () => void;
isDisabled: boolean;
text?: string;
"data-testid"?: string;
}
export function ConfigureButton({
onClick,
isDisabled,
text,
"data-testid": dataTestId,
}: ConfigureButtonProps) {
const { t } = useTranslation();
return (
<BrandButton
data-testid={dataTestId}
variant="primary"
onClick={onClick}
isDisabled={isDisabled}
type="button"
className="w-30 min-w-20"
>
{text || t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL)}
</BrandButton>
);
}
interface ConfigureModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => void;
onLink: (workspace: string) => void;
onUnlink?: () => void;
platformName: string;
platform: "jira" | "jira-dc" | "linear";
integrationData?: {
id: number;
keycloak_user_id: string;
status: string;
workspace?: {
id: number;
name: string;
status: string;
editable: boolean;
};
} | null;
}
export function ConfigureModal({
isOpen,
onClose,
onConfirm,
onLink,
onUnlink,
platformName,
platform,
integrationData,
}: ConfigureModalProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
const [serviceAccountEmail, setServiceAccountEmail] = useState("");
const [serviceAccountApiKey, setServiceAccountApiKey] = useState("");
const [isActive, setIsActive] = useState(true);
const [showConfigurationFields, setShowConfigurationFields] = useState(false);
// Determine initial state based on integrationData
const existingWorkspace = integrationData?.workspace;
const isWorkspaceEditable = existingWorkspace?.editable ?? false;
// Set initial workspace value when modal opens
React.useEffect(() => {
if (isOpen && existingWorkspace) {
setWorkspace(existingWorkspace.name);
setShowConfigurationFields(isWorkspaceEditable);
} else if (isOpen && !existingWorkspace) {
setWorkspace("");
setShowConfigurationFields(false);
}
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
// Validation states
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
null,
);
const [emailError, setEmailError] = useState<string | null>(null);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const validateMutation = useValidateIntegration(platform, {
onSuccess: (data) => {
if (data.data.status === "active") {
// Validation successful, proceed with linking
onLink(workspace.trim());
} else {
// Show configuration fields for further setup
setShowConfigurationFields(true);
setIsActive(true);
}
},
onError: (error) => {
if (error.response?.status === 404) {
// Integration not found, show configuration fields
setShowConfigurationFields(true);
setIsActive(true);
} else {
// Other errors - still show configuration fields as fallback
setShowConfigurationFields(true);
setIsActive(true);
}
},
});
// Validation functions
const validateWorkspace = (value: string) => {
const isValid = /^[a-zA-Z0-9\-_.]*$/.test(value);
if (!isValid && value.length > 0) {
setWorkspaceError(
t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR),
);
} else {
setWorkspaceError(null);
}
return isValid;
};
const validateWebhookSecret = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setWebhookSecretError(
t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR),
);
} else {
setWebhookSecretError(null);
}
return !hasSpaces;
};
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValid = emailRegex.test(value) || value.length === 0;
if (!isValid && value.length > 0) {
setEmailError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR),
);
} else {
setEmailError(null);
}
return isValid;
};
const validateApiKey = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setApiKeyError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR),
);
} else {
setApiKeyError(null);
}
return !hasSpaces;
};
// Input handlers with validation
const handleWorkspaceChange = (value: string) => {
setWorkspace(value);
validateWorkspace(value);
};
const handleWebhookSecretChange = (value: string) => {
setWebhookSecret(value);
validateWebhookSecret(value);
};
const handleEmailChange = (value: string) => {
setServiceAccountEmail(value);
validateEmail(value);
};
const handleApiKeyChange = (value: string) => {
setServiceAccountApiKey(value);
validateApiKey(value);
};
const handleClose = () => {
setWorkspace("");
setWebhookSecret("");
setServiceAccountEmail("");
setServiceAccountApiKey("");
setIsActive(false);
setShowConfigurationFields(false);
setWorkspaceError(null);
setWebhookSecretError(null);
setEmailError(null);
setApiKeyError(null);
onClose();
};
if (!isOpen) {
return null;
}
const handleConnect = () => {
if (showConfigurationFields) {
// Full configuration flow (either new configuration or editing existing)
onConfirm({
workspace,
webhookSecret,
serviceAccountEmail,
serviceAccountApiKey,
isActive,
});
} else if (!existingWorkspace) {
// First check the workspace with validation for new integrations
validateMutation.mutate(workspace.trim());
}
// For existing workspace that's not editable, no action needed
// This case shouldn't happen as the button should be hidden
};
const isConnectDisabled = showConfigurationFields
? !workspace.trim() ||
!webhookSecret.trim() ||
!serviceAccountEmail.trim() ||
!serviceAccountApiKey.trim() ||
workspaceError !== null ||
webhookSecretError !== null ||
emailError !== null ||
apiKeyError !== null ||
validateMutation.isPending
: !workspace.trim() ||
workspaceError !== null ||
validateMutation.isPending;
return (
<ModalBackdrop onClose={handleClose}>
<ModalBody className="items-start border border-tertiary w-96">
<BaseModalTitle
title={
showConfigurationFields
? t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE, {
platform: platformName,
})
: t(I18nKey.PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE)
}
/>
<BaseModalDescription>
{showConfigurationFields ? (
<Trans
i18nKey={I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Check the document for more information
</a>
),
b: <b />,
}}
/>
) : (
<p className="mt-4">
<Trans
i18nKey={
I18nKey.PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Check the document for more information
</a>
),
}}
/>
</p>
)}
</BaseModalDescription>
<div className="w-full flex flex-col gap-4 mt-4">
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER,
)}
value={workspace}
onChange={handleWorkspaceChange}
className="w-full"
type="text"
pattern="^[a-zA-Z0-9\-_.]*$"
isDisabled={!!existingWorkspace}
/>
</div>
{existingWorkspace && onUnlink && (
<BrandButton
variant="secondary"
onClick={onUnlink}
data-testid="unlink-button"
type="button"
className="mb-0"
>
{t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)}
</BrandButton>
)}
</div>
{workspaceError && (
<p className="text-red-500 text-sm mt-2">{workspaceError}</p>
)}
</div>
{showConfigurationFields && (
<>
<div>
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER,
)}
value={webhookSecret}
onChange={handleWebhookSecretChange}
className="w-full"
type="password"
/>
{webhookSecretError && (
<p className="text-red-500 text-sm mt-2">
{webhookSecretError}
</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER,
)}
value={serviceAccountEmail}
onChange={handleEmailChange}
className="w-full"
type="email"
/>
{emailError && (
<p className="text-red-500 text-sm mt-2">{emailError}</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER,
)}
value={serviceAccountApiKey}
onChange={handleApiKeyChange}
className="w-full"
type="password"
/>
{apiKeyError && (
<p className="text-red-500 text-sm mt-2">{apiKeyError}</p>
)}
</div>
<div className="mt-4">
<SettingsSwitch
testId="active-toggle"
onToggle={setIsActive}
isToggled={isActive}
>
{t(I18nKey.PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL)}
</SettingsSwitch>
</div>
</>
)}
</div>
<div className="flex flex-col gap-2 w-full mt-4">
{/* Hide the connect/edit button if workspace exists but is not editable */}
{(!existingWorkspace || isWorkspaceEditable) && (
<BrandButton
variant="primary"
onClick={handleConnect}
data-testid="connect-button"
type="button"
className="w-full"
isDisabled={isConnectDisabled}
>
{(() => {
if (existingWorkspace && showConfigurationFields) {
return t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL);
}
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
})()}
</BrandButton>
)}
<BrandButton
variant="secondary"
onClick={handleClose}
data-testid="cancel-button"
type="button"
className="w-full"
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,37 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface IntegrationButtonProps {
isLoading: boolean;
isLinked: boolean;
onClick: () => void;
"data-testid"?: string;
}
export function IntegrationButton({
isLoading,
isLinked,
onClick,
"data-testid": dataTestId,
}: IntegrationButtonProps) {
const { t } = useTranslation();
return (
<BrandButton
data-testid={dataTestId}
variant={isLinked ? "secondary" : "primary"}
onClick={onClick}
isDisabled={isLoading}
type="button"
className="w-30 min-w-20"
>
{isLoading && t(I18nKey.SETTINGS$SAVING)}
{!isLoading &&
(isLinked
? t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)
: t(I18nKey.PROJECT_MANAGEMENT$LINK_BUTTON_LABEL))}
</BrandButton>
);
}

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useIntegrationStatus } from "#/hooks/query/use-integration-status";
import { useLinkIntegration } from "#/hooks/mutation/use-link-integration";
import { useUnlinkIntegration } from "#/hooks/mutation/use-unlink-integration";
import { useConfigureIntegration } from "#/hooks/mutation/use-configure-integration";
import { I18nKey } from "#/i18n/declaration";
import {
ConfigureButton,
ConfigureModal,
} from "#/components/features/settings/project-management/configure-modal";
interface IntegrationRowProps {
platform: "jira" | "jira-dc" | "linear";
platformName: string;
"data-testid"?: string;
}
export function IntegrationRow({
platform,
platformName,
"data-testid": dataTestId,
}: IntegrationRowProps) {
const [isConfigureModalOpen, setConfigureModalOpen] = React.useState(false);
const { t } = useTranslation();
const { data: integrationData, isLoading: isStatusLoading } =
useIntegrationStatus(platform);
const linkMutation = useLinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const unlinkMutation = useUnlinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const configureMutation = useConfigureIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const handleConfigure = () => {
setConfigureModalOpen(true);
};
const handleLink = (workspace: string) => {
linkMutation.mutate(workspace);
};
const handleUnlink = () => {
unlinkMutation.mutate();
};
const handleConfigureConfirm = (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => {
configureMutation.mutate(data);
};
const isLoading =
isStatusLoading ||
linkMutation.isPending ||
unlinkMutation.isPending ||
configureMutation.isPending;
// Determine if integration is active and workspace exists
const isIntegrationActive = integrationData?.status === "active";
const hasWorkspace = integrationData?.workspace;
// Determine button text based on integration state
const buttonText =
isIntegrationActive && hasWorkspace
? t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL)
: t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL);
return (
<div className="flex items-center justify-between" data-testid={dataTestId}>
<span className="font-medium">{platformName}</span>
<div className="flex items-center gap-6">
<ConfigureButton
onClick={handleConfigure}
isDisabled={isLoading}
text={buttonText}
data-testid={`${platform}-configure-button`}
/>
</div>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={() => setConfigureModalOpen(false)}
onConfirm={handleConfigureConfirm}
onLink={handleLink}
onUnlink={handleUnlink}
platformName={platformName}
platform={platform}
integrationData={integrationData}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { IntegrationRow } from "./integration-row";
import { useConfig } from "#/hooks/query/use-config";
export function ProjectManagementIntegration() {
const { t } = useTranslation();
const { data: config } = useConfig();
return (
<div className="flex flex-col gap-4 w-1/4">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
</h3>
<div className="flex flex-col gap-4">
{config?.FEATURE_FLAGS?.ENABLE_JIRA && (
<IntegrationRow
platform="jira"
platformName="Jira Cloud"
data-testid="jira-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_JIRA_DC && (
<IntegrationRow
platform="jira-dc"
platformName="Jira Data Center"
data-testid="jira-dc-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_LINEAR && (
<IntegrationRow
platform="linear"
platformName="Linear"
data-testid="linear-integration-row"
/>
)}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import React from "react";
import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
interface UserActionsProps {
onLogout: () => void;
@@ -9,10 +10,12 @@ interface UserActionsProps {
}
export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
const { data: isAuthed } = useIsAuthed();
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
const toggleAccountMenu = () => {
// Always toggle the menu, even if user is undefined
setAccountContextMenuIsVisible((prev) => !prev);
};
@@ -25,6 +28,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
closeAccountMenu();
};
// Always show the menu for authenticated users, even without user data
const showMenu = accountContextMenuIsVisible && isAuthed;
return (
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<UserAvatar
@@ -33,7 +39,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
isLoading={isLoading}
/>
{accountContextMenuIsVisible && !!user && (
{showMenu && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}

View File

@@ -14,6 +14,7 @@ interface UserAvatarProps {
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
const { t } = useTranslation();
return (
<TooltipButton
testId="user-avatar"

View File

@@ -9,29 +9,35 @@ interface TrajectoryActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
isSaasMode?: boolean;
}
export function TrajectoryActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
isSaasMode = false,
}: TrajectoryActionsProps) {
const { t } = useTranslation();
return (
<div data-testid="feedback-actions" className="flex gap-1">
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
{!isSaasMode && (
<>
<TrajectoryActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_HELPFUL)}
/>
<TrajectoryActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
tooltip={t(I18nKey.BUTTON$MARK_NOT_HELPFUL)}
/>
</>
)}
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}

View File

@@ -35,6 +35,11 @@ export function AuthModal({
identityProvider: "bitbucket",
});
const enterpriseSsoUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "enterprise_sso",
});
const handleGitHubAuth = () => {
if (githubAuthUrl) {
// Always start the OIDC flow, let the backend handle TOS check
@@ -56,6 +61,13 @@ export function AuthModal({
}
};
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoUrl) {
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = enterpriseSsoUrl;
}
};
// Only show buttons if providers are configured and include the specific provider
const showGithub =
providersConfigured &&
@@ -69,6 +81,10 @@ export function AuthModal({
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("bitbucket");
const showEnterpriseSso =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("enterprise_sso");
// Check if no providers are configured
const noProvidersConfigured =
@@ -126,6 +142,17 @@ export function AuthModal({
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</BrandButton>
)}
{showEnterpriseSso && (
<BrandButton
type="button"
variant="primary"
onClick={handleEnterpriseSsoAuth}
className="w-full"
>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</BrandButton>
)}
</>
)}
</div>

View File

@@ -31,7 +31,7 @@ interface ConversationSubscriptionsContextType {
subscribeToConversation: (options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => void;
@@ -135,7 +135,7 @@ export function ConversationSubscriptionsProvider({
(options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => {
@@ -226,6 +226,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("connect_error", (error) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
error,
@@ -233,6 +234,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("disconnect", (reason) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
reason,

View File

@@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
interface ConfigureIntegrationData {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}
export function useConfigureIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (data: ConfigureIntegrationData) => {
const input = {
workspace_name: data.workspace,
webhook_secret: data.webhookSecret,
svc_acc_email: data.serviceAccountEmail,
svc_acc_api_key: data.serviceAccountApiKey,
is_active: data.isActive,
};
const response = await openHands.post(
`/integration/${platform}/workspaces`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Configuration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useLinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (workspace: string) => {
const input = {
workspace_name: workspace,
};
const response = await openHands.post(
`/integration/${platform}/workspaces/link`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Link integration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -1,7 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import {
BatchFeedbackData,
getFeedbackQueryKey,
} from "../query/use-batch-feedback";
type SubmitConversationFeedbackArgs = {
rating: number;
@@ -12,7 +15,6 @@ type SubmitConversationFeedbackArgs = {
export const useSubmitConversationFeedback = () => {
const { conversationId } = useConversationId();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({ rating, eventId, reason }: SubmitConversationFeedbackArgs) =>
@@ -22,18 +24,56 @@ export const useSubmitConversationFeedback = () => {
eventId,
reason,
),
onSuccess: (_, { eventId }) => {
// Invalidate the feedback existence query to trigger a refetch
onMutate: async ({ rating, eventId, reason }) => {
if (!eventId) return { previousFeedback: null };
// Get the query key for the feedback data
const queryKey = getFeedbackQueryKey(conversationId);
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous value
const previousFeedback =
queryClient.getQueryData<Record<string, BatchFeedbackData>>(queryKey);
// Optimistically update the cache
queryClient.setQueryData<Record<string, BatchFeedbackData>>(
queryKey,
(old = {}) => {
const newData = { ...old };
newData[eventId.toString()] = {
exists: true,
rating,
reason,
metadata: { source: "likert-scale" },
};
return newData;
},
);
// Return a context object with the snapshotted value
return { previousFeedback };
},
onError: (error, { eventId }, context) => {
// Roll back to the previous value on error
if (context?.previousFeedback && eventId) {
queryClient.setQueryData(
getFeedbackQueryKey(conversationId),
context.previousFeedback,
);
}
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
// eslint-disable-next-line no-console
console.error(error);
},
onSettled: (_, __, { eventId }) => {
if (eventId) {
// Invalidate both the old and new query keys to ensure consistency
queryClient.invalidateQueries({
queryKey: ["feedback", "exists", conversationId, eventId],
queryKey: getFeedbackQueryKey(conversationId),
});
}
},
onError: (error) => {
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
// eslint-disable-next-line no-console
console.error(t("FEEDBACK$FAILED_TO_SUBMIT"), error);
},
});
};

View File

@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useUnlinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: () =>
openHands.post(`/integration/${platform}/workspaces/unlink`),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useValidateIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSuccess,
onError,
}: {
onSuccess: (data: any) => void;
onError: (error: any) => void;
},
) {
const { t } = useTranslation();
return useMutation({
mutationFn: (workspace?: string) => {
const workspaceParam = workspace ? `/${workspace}` : "";
return openHands.get(
`/integration/${platform}/workspaces/validate${workspaceParam}`,
);
},
onSuccess,
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
onError(error);
} else {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(
errorMessage ||
t(I18nKey.PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR),
);
}
},
});
}

View File

@@ -0,0 +1,52 @@
import React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
export interface BatchFeedbackData {
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, unknown>;
}
// Query key factory to ensure consistency across hooks
export const getFeedbackQueryKey = (conversationId?: string) =>
["feedback", "data", conversationId] as const;
// Query key factory for individual feedback existence
export const getFeedbackExistsQueryKey = (
conversationId: string,
eventId: number,
) => ["feedback", "exists", conversationId, eventId] as const;
export const useBatchFeedback = () => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const queryClient = useQueryClient();
const runtimeIsReady = useRuntimeIsReady();
const query = useQuery({
queryKey: getFeedbackQueryKey(conversationId),
queryFn: () => OpenHands.getBatchFeedback(conversationId!),
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
// Update individual feedback cache entries when batch data changes
React.useEffect(() => {
if (query.data && conversationId) {
Object.entries(query.data).forEach(([eventId, feedback]) => {
queryClient.setQueryData(
getFeedbackExistsQueryKey(conversationId, parseInt(eventId, 10)),
feedback,
);
});
}
}, [query.data, conversationId, queryClient]);
return query;
};

View File

@@ -1,25 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
export interface FeedbackData {
exists: boolean;
rating?: number;
reason?: string;
}
export type FeedbackData = BatchFeedbackData;
export const useFeedbackExists = (eventId?: number) => {
const queryClient = useQueryClient();
const { conversationId } = useConversationId();
const { data: config } = useConfig();
return useQuery<FeedbackData>({
queryKey: ["feedback", "exists", conversationId, eventId],
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
queryFn: () => {
if (!eventId) return { exists: false };
return OpenHands.checkFeedbackExists(conversationId, eventId);
// Try to get the data from the batch cache
const batchData = queryClient.getQueryData<
Record<string, BatchFeedbackData>
>(getFeedbackQueryKey(conversationId));
return batchData?.[eventId.toString()] ?? { exists: false };
},
enabled: !!eventId && config?.APP_MODE === "saas",
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useUserProviders } from "../use-user-providers";
import { useConfig } from "./use-config";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { providers } = useUserProviders();
const { data: isAuthed } = useIsAuthed();
const isOss = config?.APP_MODE === "oss";
return useQuery({
queryKey: ["secrets"],
queryFn: SecretsService.getSecrets,
enabled: isOss || providers.length > 0,
enabled: isOss || isAuthed, // Enable regardless of providers
});
};

View File

@@ -3,16 +3,16 @@ import React from "react";
import posthog from "posthog-js";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGitUser = () => {
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: isAuthed } = useIsAuthed();
const user = useQuery({
queryKey: ["user"],
queryFn: OpenHands.getGitUser,
enabled: !!config?.APP_MODE && providers.length > 0,
enabled: !!config?.APP_MODE && isAuthed, // Enable regardless of providers
retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { openHands } from "#/api/open-hands-axios";
export function useIntegrationStatus(platform: "jira" | "jira-dc" | "linear") {
return useQuery({
queryKey: ["integration-status", platform],
queryFn: async () => {
try {
const response = await openHands.get(
`/integration/${platform}/workspaces/link`,
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
},
});
}

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useRepositoryMicroagentContent = (
owner: string,
repo: string,
filePath: string,
cacheDisabled: boolean = false,
) =>
useQuery({
queryKey: ["repository", "microagent", "content", owner, repo, filePath],
queryFn: () =>
OpenHands.getRepositoryMicroagentContent(owner, repo, filePath),
enabled: !!owner && !!repo && !!filePath,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
});

View File

@@ -31,6 +31,11 @@ export const useAutoLogin = () => {
identityProvider: "bitbucket",
});
const enterpriseSsoUrl = useAuthUrl({
appMode: config?.APP_MODE || null,
identityProvider: "enterprise_sso",
});
useEffect(() => {
// Only auto-login in SAAS mode
if (config?.APP_MODE !== "saas") {
@@ -60,6 +65,8 @@ export const useAutoLogin = () => {
authUrl = gitlabAuthUrl;
} else if (loginMethod === LoginMethod.BITBUCKET) {
authUrl = bitbucketAuthUrl;
} else if (loginMethod === LoginMethod.ENTERPRISE_SSO) {
authUrl = enterpriseSsoUrl;
}
// If we have an auth URL, redirect to it
@@ -80,5 +87,6 @@ export const useAutoLogin = () => {
githubAuthUrl,
gitlabAuthUrl,
bitbucketAuthUrl,
enterpriseSsoUrl,
]);
};

View File

@@ -22,8 +22,18 @@ const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
};
const renderCommand = (command: Command, terminal: Terminal) => {
const { content } = command;
const renderCommand = (
command: Command,
terminal: Terminal,
isUserInput: boolean = false,
) => {
const { content, type } = command;
// Skip rendering user input commands that come from the event stream
// as they've already been displayed in the terminal as the user typed
if (type === "input" && isUserInput) {
return;
}
terminal.writeln(
parseTerminalOutput(content.replaceAll("\n", "\r\n").trim()),
@@ -123,7 +133,9 @@ export const useTerminal = ({
if (commands[i].type === "input") {
terminal.current.write("$ ");
}
renderCommand(commands[i], terminal.current);
// Don't pass isUserInput=true here because we're initializing the terminal
// and need to show all previous commands
renderCommand(commands[i], terminal.current, false);
}
lastCommandIndex.current = commands.length;
}
@@ -144,7 +156,9 @@ export const useTerminal = ({
let lastCommandType = "";
for (let i = lastCommandIndex.current; i < commands.length; i += 1) {
lastCommandType = commands[i].type;
renderCommand(commands[i], terminal.current);
// Pass true for isUserInput to skip rendering user input commands
// that have already been displayed as the user typed
renderCommand(commands[i], terminal.current, true);
}
lastCommandIndex.current = commands.length;
if (lastCommandType === "output") {

View File

@@ -1,5 +1,6 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
MAINTENANCE$SCHEDULED_MESSAGE = "MAINTENANCE$SCHEDULED_MESSAGE",
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
@@ -106,6 +107,8 @@ export enum I18nKey {
SETTINGS$NAV_CREDITS = "SETTINGS$NAV_CREDITS",
SETTINGS$NAV_SECRETS = "SETTINGS$NAV_SECRETS",
SETTINGS$NAV_API_KEYS = "SETTINGS$NAV_API_KEYS",
SETTINGS$GITHUB = "SETTINGS$GITHUB",
SETTINGS$SLACK = "SETTINGS$SLACK",
SETTINGS$NAV_LLM = "SETTINGS$NAV_LLM",
GIT$MERGE_REQUEST = "GIT$MERGE_REQUEST",
GIT$GITLAB_API = "GIT$GITLAB_API",
@@ -556,6 +559,7 @@ export enum I18nKey {
GITHUB$CONNECT_TO_GITHUB = "GITHUB$CONNECT_TO_GITHUB",
GITLAB$CONNECT_TO_GITLAB = "GITLAB$CONNECT_TO_GITLAB",
BITBUCKET$CONNECT_TO_BITBUCKET = "BITBUCKET$CONNECT_TO_BITBUCKET",
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
@@ -733,4 +737,29 @@ export enum I18nKey {
MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$WHAT_YOU_WOULD_LIKE_TO_KNOW_ABOUT_THIS_REPO",
MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO = "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_KNOW_ABOUT_THIS_REPO",
MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION = "MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT_MODAL_DESCRIPTION",
PROJECT_MANAGEMENT$TITLE = "PROJECT_MANAGEMENT$TITLE",
PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR = "PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR",
PROJECT_MANAGEMENT$LINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$LINK_BUTTON_LABEL",
PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL",
PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE = "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE",
PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL",
PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL = "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL",
PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL = "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL",
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER",
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL",
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER",
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL",
PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER = "PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER",
PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL",
PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL = "PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION",
PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE = "PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE",
PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION = "PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION",
PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR",
PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR = "PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR",
PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR",
PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR = "PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR",
MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT = "MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT",
}

View File

@@ -1,4 +1,20 @@
{
"MAINTENANCE$SCHEDULED_MESSAGE": {
"en": "Scheduled maintenance will begin at {{time}}",
"ja": "予定されたメンテナンスは{{time}}に開始されます",
"zh-CN": "计划维护将于{{time}}开始",
"zh-TW": "計劃維護將於{{time}}開始",
"ko-KR": "예정된 유지보수가 {{time}}에 시작됩니다",
"no": "Planlagt vedlikehold starter {{time}}",
"it": "La manutenzione programmata inizierà alle {{time}}",
"pt": "A manutenção programada começará às {{time}}",
"es": "El mantenimiento programado comenzará a las {{time}}",
"ar": "ستبدأ الصيانة المجدولة في {{time}}",
"fr": "La maintenance programmée commencera à {{time}}",
"tr": "Planlı bakım {{time}} tarihinde başlayacak",
"de": "Die geplante Wartung beginnt um {{time}}",
"uk": "Планове технічне обслуговування розпочнеться о {{time}}"
},
"MICROAGENT$NO_REPOSITORY_FOUND": {
"en": "No repository found to launch microagent",
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
@@ -1695,6 +1711,38 @@
"de": "API-Schlüssel",
"uk": "API ключі"
},
"SETTINGS$GITHUB": {
"en": "GitHub",
"ja": "GitHub",
"zh-CN": "GitHub",
"zh-TW": "GitHub",
"ko-KR": "GitHub",
"no": "GitHub",
"it": "GitHub",
"pt": "GitHub",
"es": "GitHub",
"ar": "GitHub",
"fr": "GitHub",
"tr": "GitHub",
"de": "GitHub",
"uk": "GitHub"
},
"SETTINGS$SLACK": {
"en": "Slack",
"ja": "Slack",
"zh-CN": "Slack",
"zh-TW": "Slack",
"ko-KR": "Slack",
"no": "Slack",
"it": "Slack",
"pt": "Slack",
"es": "Slack",
"ar": "Slack",
"fr": "Slack",
"tr": "Slack",
"de": "Slack",
"uk": "Slack"
},
"SETTINGS$NAV_LLM": {
"en": "LLM",
"ja": "LLM",
@@ -4416,20 +4464,20 @@
"uk": "Від'єднано"
},
"CHAT_INTERFACE$CONNECTING": {
"en": "Connecting",
"ja": "接続中",
"zh-CN": "正在连接",
"zh-TW": "正在連接",
"ko-KR": "연결 중",
"no": "Kobler til",
"it": "Connessione in corso",
"pt": "Conectando",
"es": "Conectando",
"ar": "جاري الاتصال",
"fr": "Connexion en cours",
"tr": "Bağlanıyor",
"de": "Verbindung wird hergestellt",
"uk": "З'єднання"
"en": "Connecting... (this may take 1-2 minutes)",
"ja": "接続中...1-2分かかる場合があります",
"zh-CN": "正在连接...这可能需要1-2分钟",
"zh-TW": "正在連接...這可能需要1-2分鐘",
"ko-KR": "연결 중... (1-2분 정도 걸릴 수 있습니다)",
"no": "Kobler til... (dette kan ta 1-2 minutter)",
"it": "Connessione in corso... (potrebbe richiedere 1-2 minuti)",
"pt": "Conectando... (isso pode levar 1-2 minutos)",
"es": "Conectando... (esto puede tomar 1-2 minutos)",
"ar": "جاري الاتصال... (قد يستغرق هذا 1-2 دقيقة)",
"fr": "Connexion en cours... (cela peut prendre 1-2 minutes)",
"tr": "Bağlanıyor... (bu 1-2 dakika sürebilir)",
"de": "Verbindung wird hergestellt... (dies kann 1-2 Minuten dauern)",
"uk": "З'єднання... (це може зайняти 1-2 хвилини)"
},
"CHAT_INTERFACE$STOPPED": {
"en": "Stopped",
@@ -6176,20 +6224,20 @@
"uk": "Ви можете знайти свій ключ API OpenHands у"
},
"SETTINGS$OPENHANDS_API_KEY_HELP_SUFFIX": {
"en": "tab of OpenHands Cloud.",
"ja": "タブで確認できます。",
"zh-CN": "标签页中找到您的OpenHands API密钥。",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。",
"ko-KR": "탭에서 찾을 수 있습니다.",
"no": "-fanen i OpenHands Cloud.",
"it": "scheda di OpenHands Cloud.",
"pt": "guia do OpenHands Cloud.",
"es": "pestaña de OpenHands Cloud.",
"ar": "علامة التبويب في OpenHands Cloud.",
"fr": "l'onglet d'OpenHands Cloud.",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz.",
"de": "Tab von OpenHands Cloud.",
"uk": "вкладці OpenHands Cloud."
"en": "tab of OpenHands Cloud. LLM usage is billed at the providers' rates with no markup. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ja": "タブで確認できます。LLMの使用料金は、プロバイダーの料金でマークアップなしで請求されます。詳細: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-CN": "标签页中找到您的OpenHands API密钥。LLM使用费用按提供商费率计费无加价。详情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"zh-TW": "標籤頁中找到您的OpenHands API密鑰。LLM使用費用按提供商費率計費無加價。詳情: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ko-KR": "탭에서 찾을 수 있습니다. LLM 사용료는 제공업체 요금으로 마크업 없이 청구됩니다. 자세한 내용: https://docs.all-hands.dev/usage/llms/openhands-llms",
"no": "-fanen i OpenHands Cloud. LLM-bruk faktureres til leverandørenes priser uten påslag. Detaljer: https://docs.all-hands.dev/usage/llms/openhands-llms",
"it": "scheda di OpenHands Cloud. L'utilizzo di LLM viene fatturato alle tariffe dei fornitori senza ricarico. Dettagli: https://docs.all-hands.dev/usage/llms/openhands-llms",
"pt": "guia do OpenHands Cloud. O uso de LLM é cobrado nas tarifas dos provedores sem markup. Detalhes: https://docs.all-hands.dev/usage/llms/openhands-llms",
"es": "pestaña de OpenHands Cloud. El uso de LLM se factura a las tarifas de los proveedores sin recargo. Detalles: https://docs.all-hands.dev/usage/llms/openhands-llms",
"ar": "علامة التبويب في OpenHands Cloud. يتم فوترة استخدام LLM بأسعار المزودين بدون زيادة. التفاصيل: https://docs.all-hands.dev/usage/llms/openhands-llms",
"fr": "l'onglet d'OpenHands Cloud. L'utilisation de LLM est facturée aux tarifs des fournisseurs sans majoration. Détails : https://docs.all-hands.dev/usage/llms/openhands-llms",
"tr": "OpenHands Cloud'un sekmesinde bulabilirsiniz. LLM kullanımı, sağlayıcıların oranlarında ek ücret olmadan faturalandırılır. Ayrıntılar: https://docs.all-hands.dev/usage/llms/openhands-llms",
"de": "Tab von OpenHands Cloud. LLM-Nutzung wird zu Anbieterpreisen ohne Aufschlag abgerechnet. Details: https://docs.all-hands.dev/usage/llms/openhands-llms",
"uk": "вкладці OpenHands Cloud. Використання LLM оплачується за тарифами провайдерів без надбавки. Деталі: https://docs.all-hands.dev/usage/llms/openhands-llms"
},
"SETTINGS$CREATE_API_KEY": {
"en": "Create API Key",
@@ -6624,20 +6672,20 @@
"uk": "Підключення до середовища виконання..."
},
"STATUS$STARTING_RUNTIME": {
"en": "Starting runtime...",
"zh-CN": "启动运行时...",
"zh-TW": "啟動執行時...",
"de": "Laufzeitumgebung wird gestartet...",
"ko-KR": "런타임 시작 중...",
"no": "Starter kjøretidsmiljø...",
"it": "Avvio dell'ambiente di esecuzione...",
"pt": "Iniciando o ambiente de execução...",
"es": "Iniciando el entorno de ejecución...",
"ar": "جارٍ بدء بيئة التشغيل...",
"fr": "Démarrage de l'environnement d'exécution...",
"tr": "Çalışma zamanı başlatılıyor...",
"ja": "ランタイムを開始中",
"uk": "Запуск середовища виконання..."
"en": "Starting runtime... (this may take 1-2 minutes)",
"zh-CN": "启动运行时...这可能需要1-2分钟",
"zh-TW": "啟動執行時...這可能需要1-2分鐘",
"de": "Laufzeitumgebung wird gestartet... (dies kann 1-2 Minuten dauern)",
"ko-KR": "런타임 시작 중... (1-2분 정도 걸릴 수 있습니다)",
"no": "Starter kjøretidsmiljø... (dette kan ta 1-2 minutter)",
"it": "Avvio dell'ambiente di esecuzione... (potrebbe richiedere 1-2 minuti)",
"pt": "Iniciando o ambiente de execução... (isso pode levar 1-2 minutos)",
"es": "Iniciando el entorno de ejecución... (esto puede tomar 1-2 minutos)",
"ar": "جارٍ بدء بيئة التشغيل... (قد يستغرق هذا 1-2 دقيقة)",
"fr": "Démarrage de l'environnement d'exécution... (cela peut prendre 1-2 minutes)",
"tr": "Çalışma zamanı başlatılıyor... (bu 1-2 dakika sürebilir)",
"ja": "ランタイムを開始中...1-2分かかる場合があります",
"uk": "Запуск середовища виконання... (це може зайняти 1-2 хвилини)"
},
"STATUS$SETTING_UP_WORKSPACE": {
"en": "Setting up workspace...",
@@ -8895,6 +8943,22 @@
"tr": "Bitbucket'a bağlan",
"uk": "Увійти за допомогою Bitbucket"
},
"ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO": {
"en": "Login with Enterprise SSO",
"ja": "エンタープライズSSOでログイン",
"zh-CN": "使用企业SSO登录",
"zh-TW": "使用企業SSO登入",
"ko-KR": "엔터프라이즈 SSO로 로그인",
"de": "Mit Enterprise SSO anmelden",
"no": "Logg inn med Enterprise SSO",
"it": "Accedi con Enterprise SSO",
"pt": "Entrar com Enterprise SSO",
"es": "Iniciar sesión con Enterprise SSO",
"ar": "تسجيل الدخول باستخدام Enterprise SSO",
"fr": "Se connecter avec Enterprise SSO",
"tr": "Enterprise SSO ile giriş yap",
"uk": "Увійти за допомогою Enterprise SSO"
},
"AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER": {
"en": "Log in to OpenHands",
"ja": "IDプロバイダーでサインイン",
@@ -10688,20 +10752,20 @@
"uk": "Відключити токени"
},
"API$TAVILY_KEY_EXAMPLE": {
"en": "sk-tavily-...",
"ja": "sk-tavily-...",
"zh-CN": "sk-tavily-...",
"zh-TW": "sk-tavily-...",
"ko-KR": "sk-tavily-...",
"no": "sk-tavily-...",
"it": "sk-tavily-...",
"pt": "sk-tavily-...",
"es": "sk-tavily-...",
"ar": "sk-tavily-...",
"fr": "sk-tavily-...",
"tr": "sk-tavily-...",
"de": "sk-tavily-...",
"uk": "sk-tavily-..."
"en": "tvly-dev-...",
"ja": "tvly-dev-...",
"zh-CN": "tvly-dev-...",
"zh-TW": "tvly-dev-...",
"ko-KR": "tvly-dev-...",
"no": "tvly-dev-...",
"it": "tvly-dev-...",
"pt": "tvly-dev-...",
"es": "tvly-dev-...",
"ar": "tvly-dev-...",
"fr": "tvly-dev-...",
"tr": "tvly-dev-...",
"de": "tvly-dev-...",
"uk": "tvly-dev-..."
},
"API$TVLY_KEY_EXAMPLE": {
"en": "tvly-...",
@@ -11726,5 +11790,405 @@
"tr": "OpenHands, talimatlarınıza göre mikro ajanı güncelleyecektir.",
"de": "OpenHands aktualisiert den Microagenten basierend auf Ihren Anweisungen.",
"uk": "OpenHands оновить мікроагента відповідно до ваших інструкцій."
},
"PROJECT_MANAGEMENT$TITLE": {
"en": "Project Management",
"ja": "プロジェクト管理",
"zh-CN": "项目管理",
"zh-TW": "專案管理",
"ko-KR": "프로젝트 관리",
"no": "Prosjektledelse",
"it": "Gestione del progetto",
"pt": "Gerenciamento de projetos",
"es": "Gestión de proyectos",
"ar": "إدارة المشاريع",
"fr": "Gestion de projet",
"tr": "Proje Yönetimi",
"de": "Projektmanagement",
"uk": "Управління проектами"
},
"PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR": {
"en": "Failed to validate integration. Please try again later.",
"ja": "統合の検証に失敗しました。しばらくしてからもう一度お試しください.",
"zh-CN": "无法验证集成。请稍后重试.",
"zh-TW": "無法驗證整合。請稍後重試.",
"ko-KR": "통합을 검증하지 못했습니다. 나중에 다시 시도해 주세요.",
"no": "Kunne ikke validere integrasjonen. Prøv på nytt senere.",
"it": "Impossibile convalidare l'integrazione. Riprova più tardi.",
"pt": "Falha ao validar a integração. Tente novamente mais tarde.",
"es": "No se pudo validar la integración. Inténtelo de nuevo más tarde.",
"ar": "فشل التحقق من التكامل. يُرجى المحاولة لاحقًا.",
"fr": "Échec de la validation de l'intégration. Veuillez réessayer ultérieurement.",
"tr": "Entegrasyon doğrulanamadı. Lütfen daha sonra tekrar deneyin.",
"de": "Die Integration konnte nicht validiert werden. Bitte versuchen Sie es später noch einmal.",
"uk": "Не вдалося перевірити інтеграцію. Спробуйте пізніше."
},
"PROJECT_MANAGEMENT$LINK_BUTTON_LABEL": {
"en": "Link",
"ja": "リンク",
"zh-CN": "关联",
"zh-TW": "關聯",
"ko-KR": "링크",
"no": "Link",
"it": "Collegamento",
"pt": "Link",
"es": "Enlace",
"ar": "وصلة",
"fr": "Lien",
"tr": "Bağlantı",
"de": "Link",
"uk": "Посилання"
},
"PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL": {
"en": "Unlink",
"ja": "リンクを解除する",
"zh-CN": "取消链接",
"zh-TW": "取消連結",
"ko-KR": "풀리다",
"no": "Fjern tilknytningen",
"it": "Scollega",
"pt": "Desvincular",
"es": "Desconectar",
"ar": "إلغاء الارتباط",
"fr": "Dissocier",
"tr": "Bağlantıyı kaldır",
"de": "Verknüpfung aufheben",
"uk": "Від’єднати"
},
"PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE": {
"en": "Link Workspace",
"ja": "ワークスペースをリンク",
"zh-CN": "链接工作区",
"zh-TW": "連結工作區",
"ko-KR": "작업공간 링크",
"no": "Link arbeidsområde",
"it": "Collega area di lavoro",
"pt": "Vincular espaço de trabalho",
"es": "Vincular espacio de trabajo",
"ar": "ربط مساحة العمل",
"fr": "Lier l'espace de travail",
"tr": "Çalışma Alanını Bağla",
"de": "Arbeitsbereich verknüpfen",
"uk": "Пов'язати робочу область"
},
"PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL": {
"en": "Configure",
"ja": "設定する",
"zh-CN": "配置",
"zh-TW": "配置",
"ko-KR": "구성",
"no": "Konfigurer",
"it": "Configura",
"pt": "Configurar",
"es": "Configurar",
"ar": "تكوين",
"fr": "Configurer",
"tr": "Yapılandır",
"de": "Konfigurieren",
"uk": "Налаштувати"
},
"PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL": {
"en": "Edit",
"ja": "編集",
"zh-CN": "编辑",
"zh-TW": "編輯",
"ko-KR": "편집",
"no": "Rediger",
"it": "Modifica",
"pt": "Editar",
"es": "Editar",
"ar": "تحرير",
"fr": "Modifier",
"tr": "Düzenle",
"de": "Bearbeiten",
"uk": "Редагувати"
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL": {
"en": "Workspace Name",
"ja": "ワークスペース名",
"zh-CN": "工作区名称",
"zh-TW": "工作區名稱",
"ko-KR": "작업공간 이름",
"no": "Navn på arbeidsområde",
"it": "Nome dell'area di lavoro",
"pt": "Nome do espaço de trabalho",
"es": "Nombre del espacio de trabajo",
"ar": "اسم مساحة العمل",
"fr": "Nom de l'espace de travail",
"tr": "Çalışma Alanı Adı",
"de": "Arbeitsbereichsname",
"uk": "Назва робочої області"
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER": {
"en": "myworkspace",
"ja": "私のワークスペース",
"zh-CN": "我的工作空间",
"zh-TW": "我的工作區",
"ko-KR": "내워크스페이스",
"no": "mittarbeidsområde",
"it": "mioworkspace",
"pt": "meuworkspace",
"es": "miespaciodetrabajo",
"ar": "مساحةعملي",
"fr": "monworkspace",
"tr": "benimworkspace",
"de": "meinarbeitsbereich",
"uk": "моя-робоча-область"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL": {
"en": "Webhook Secret",
"ja": "Webhook シークレット",
"zh-CN": "Webhook 秘密",
"zh-TW": "Webhook 秘密",
"ko-KR": "웹훅 비밀",
"no": "Webhook Secret",
"it": "Segreto del webhook",
"pt": "Segredo do webhook",
"es": "Secreto del webhook",
"ar": "سر الويب هوك",
"fr": "Secret du webhook",
"tr": "Web Kancası Sırrı",
"de": "Webhook-Geheimnis",
"uk": "Секрет вебхуку"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER": {
"en": "whsec_abcd1234efgh5678ijkl",
"ja": "whsec_abcd1234efgh5678ijkl",
"zh-CN": "whsec_abcd1234efgh5678ijkl",
"zh-TW": "whsec_abcd1234efgh5678ijkl",
"ko-KR": "whsec_abcd1234efgh5678ijkl",
"no": "whsec_abcd1234efgh5678ijkl",
"it": "whsec_abcd1234efgh5678ijkl",
"pt": "whsec_abcd1234efgh5678ijkl",
"es": "whsec_abcd1234efgh5678ijkl",
"ar": "whsec_abcd1234efgh5678ijkl",
"fr": "whsec_abcd1234efgh5678ijkl",
"tr": "whsec_abcd1234efgh5678ijkl",
"de": "whsec_abcd1234efgh5678ijkl",
"uk": "whsec_abcd1234efgh5678ijkl"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL": {
"en": "Service Account Email",
"ja": "サービスアカウントのメールアドレス",
"zh-CN": "服务帐户电子邮件",
"zh-TW": "服務帳號電子郵件",
"ko-KR": "서비스 계정 이메일",
"no": "Tjenestekonto e-post",
"it": "E-mail dell'account di servizio",
"pt": "E-mail da conta de serviço",
"es": "Correo electrónico de cuenta de servicio",
"ar": "البريد الإلكتروني لحساب الخدمة",
"fr": "E-mail du compte de service",
"tr": "Hizmet Hesabı E-postası",
"de": "E-Mail-Adresse des Dienstkontos",
"uk": "Електронна адреса облікового запису служби"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER": {
"en": "test@example.com",
"ja": "test@example.com",
"zh-CN": "test@example.com",
"zh-TW": "test@example.com",
"ko-KR": "test@example.com",
"no": "test@example.com",
"it": "test@example.com",
"pt": "test@example.com",
"es": "test@example.com",
"ar": "test@example.com",
"fr": "test@example.com",
"tr": "test@example.com",
"de": "test@example.com",
"uk": "test@example.com"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL": {
"en": "Service Account API Key",
"ja": "サービスアカウントAPIキー",
"zh-CN": "服务帐户 API 密钥",
"zh-TW": "服務帳戶 API 金鑰",
"ko-KR": "서비스 계정 API 키",
"no": "Tjenestekonto API-nøkkel",
"it": "Chiave API dell'account di servizio",
"pt": "Chave de API da conta de serviço",
"es": "Clave API de cuenta de servicio",
"ar": "مفتاح API لحساب الخدمة",
"fr": "Clé API du compte de service",
"tr": "Hizmet Hesabı API Anahtarı",
"de": "API-Schlüssel des Dienstkontos",
"uk": "Ключ API облікового запису служби"
},
"PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER": {
"en": "sk-1234567890abcdef...",
"ja": "sk-1234567890abcdef...",
"zh-CN": "sk-1234567890abcdef...",
"zh-TW": "sk-1234567890abcdef...",
"ko-KR": "sk-1234567890abcdef...",
"no": "sk-1234567890abcdef...",
"it": "sk-1234567890abcdef...",
"pt": "sk-1234567890abcdef...",
"es": "sk-1234567890abcdef...",
"ar": "sk-1234567890abcdef...",
"fr": "sk-1234567890abcdef...",
"tr": "sk-1234567890abcdef...",
"de": "sk-1234567890abcdef...",
"uk": "sk-1234567890abcdef..."
},
"PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL": {
"en": "Connect",
"ja": "接続する",
"zh-CN": "连接",
"zh-TW": "連接",
"ko-KR": "연결하다",
"no": "Koble til",
"it": "Collegare",
"pt": "Conectar",
"es": "Conectar",
"ar": "يتصل",
"fr": "Connecter",
"tr": "Bağlamak",
"de": "Verbinden",
"uk": "Підключитися"
},
"PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL": {
"en": "Active",
"ja": "アクティブ",
"zh-CN": "积极的",
"zh-TW": "積極的",
"ko-KR": "활동적인",
"no": "Aktiv",
"it": "Attiva",
"pt": "Ativa",
"es": "Activa",
"ar": "نشيط",
"fr": "Actif",
"tr": "Aktif",
"de": "Aktiv",
"uk": "Активний"
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION": {
"en": "<b>Important:</b> Check the <a>documentation</a> for more information about configuring the workspace integration or updating an existing integration.",
"ja": "<b>重要:</b> ワークスペース統合の設定や既存の統合の更新について詳しくは<a>ドキュメント</a>をご確認ください。",
"zh-CN": "<b>重要提示:</b>有关配置工作区集成或更新现有集成的更多信息,请查看<a>文档</a>。",
"zh-TW": "<b>重要提示:</b>有關配置工作區整合或更新現有整合的更多資訊,請查看<a>文件</a>。",
"ko-KR": "<b>중요:</b> 작업공간 통합 구성이나 기존 통합 업데이트에 대한 자세한 내용은 <a>설명서</a>를 확인하세요.",
"no": "<b>Viktig:</b> Se <a>dokumentasjonen</a> for mer informasjon om konfigurering av arbeidsområdeintegrasjon eller oppdatering av eksisterende integrasjon.",
"it": "<b>Importante:</b> Consulta la <a>documentazione</a> per ulteriori informazioni sulla configurazione dell'integrazione dell'area di lavoro o sull'aggiornamento di un'integrazione esistente.",
"pt": "<b>Importante:</b> Consulte a <a>documentação</a> para obter mais informações sobre como configurar a integração do workspace ou atualizar uma integração existente.",
"es": "<b>Importante:</b> Consulte la <a>documentación</a> para obtener más información sobre la configuración de la integración del espacio de trabajo o la actualización de una integración existente.",
"ar": "<b>هام:</b> راجع <a>الوثائق</a> لمزيد من المعلومات حول تكوين تكامل مساحة العمل أو تحديث تكامل موجود.",
"fr": "<b>Important :</b> Consultez la <a>documentation</a> pour plus d'informations sur la configuration de l'intégration de l'espace de travail ou la mise à jour d'une intégration existante.",
"tr": "<b>Önemli:</b> Çalışma alanı entegrasyonunu yapılandırma veya mevcut bir entegrasyonu güncelleme hakkında daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b> Weitere Informationen zur Konfiguration der Arbeitsbereichsintegration oder zur Aktualisierung einer bestehenden Integration finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо:</b> Перегляньте <a>документацію</a> для отримання додаткової інформації про налаштування інтеграції робочого простору або оновлення існуючої інтеграції."
},
"PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE": {
"en": "Configure {{platform}} Integration",
"ja": "{{platform}}統合を設定",
"zh-CN": "配置{{platform}}集成",
"zh-TW": "配置{{platform}}集成",
"ko-KR": "{{platform}} 통합 구성",
"no": "Konfigurer {{platform}}-integrasjon",
"it": "Configura integrazione {{platform}}",
"pt": "Configurar integração {{platform}}",
"es": "Configurar integración de {{platform}}",
"ar": "تكوين تكامل {{platform}}",
"fr": "Configurer l'intégration {{platform}}",
"tr": "{{platform}} Entegrasyonunu Yapılandır",
"de": "{{platform}}-Integration konfigurieren",
"uk": "Налаштувати інтеграцію {{platform}}"
},
"PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION": {
"en": "<b>Important: </b>Make sure the workspace integration for your target workspace is already configured. Check the <a>documentation</a> for more information.",
"ja": "<b>重要: </b>対象のワークスペースのワークスペース統合がすでに設定されていることを確認してください。詳しくは<a>ドキュメント</a>をご覧ください。",
"zh-CN": "<b>重要提示:</b>请确保目标工作区的工作区集成已配置完毕。查看<a>文档</a>了解更多信息。",
"zh-TW": "<b>重要提示:</b>請確保目標工作區的工作區整合已設定完成。查看<a>文件</a>以了解更多資訊。",
"ko-KR": "<b>중요:</b>대상 작업 공간에 대한 작업 공간 통합이 이미 구성되어 있는지 확인하세요. 자세한 내용은 <a>설명서</a>를 참조하세요.",
"no": "<b>Viktig:</b>Sørg for at arbeidsområdeintegrasjonen for målarbeidsområdet ditt allerede er konfigurert. Se <a>dokumentasjonen</a> for mer informasjon.",
"it": "<b>Importante: </b>Assicurati che l'integrazione dell'area di lavoro per l'area di lavoro di destinazione sia già configurata. Consulta la <a>documentazione</a> per ulteriori informazioni.",
"pt": "<b>Importante:</b>Certifique-se de que a integração do workspace de destino já esteja configurada. Consulte a <a>documentação</a> para obter mais informações.",
"es": "Importante: Asegúrate de que la integración del espacio de trabajo de destino ya esté configurada. Consulta la documentación para obtener más información.",
"ar": "<b>هام: </b>تأكد من إعداد تكامل مساحة العمل لمساحة العمل المستهدفة. <a>راجع المستند لمزيد من المعلومات</a>.",
"fr": "<b>Important :</b>Assurez-vous que l'intégration de l'espace de travail cible est déjà configurée. Consultez la <a>documentation</a> pour plus d'informations.",
"tr": "<b>Önemli: </b>Hedef çalışma alanınız için çalışma alanı entegrasyonunun zaten yapılandırılmış olduğundan emin olun. Daha fazla bilgi için <a>belgelere</a> bakın.",
"de": "<b>Wichtig:</b>Stellen Sie sicher, dass die Arbeitsbereichsintegration für Ihren Zielarbeitsbereich bereits konfiguriert ist. Weitere Informationen finden Sie in der <a>Dokumentation</a>.",
"uk": "<b>Важливо: </b>Переконайтеся, що інтеграцію робочого простору для вашого цільового робочого простору вже налаштовано. Перегляньте <a>документацію</a> для отримання додаткової інформації."
},
"PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR": {
"en": "Workspace name can only contain letters, numbers, hyphens, and underscores",
"ja": "ワークスペース名は文字、数字、ハイフン、アンダースコアのみ使用できます",
"zh-CN": "工作区名称只能包含字母、数字、连字符和下划线",
"zh-TW": "工作區名稱只能包含字母、數字、連字符和底線",
"ko-KR": "작업공간 이름은 문자, 숫자, 하이픈, 밑줄만 포함할 수 있습니다",
"no": "Arbeidsområdenavn kan bare inneholde bokstaver, tall, bindestrek og understrek",
"it": "Il nome dell'area di lavoro può contenere solo lettere, numeri, trattini e trattini bassi",
"pt": "O nome do workspace pode conter apenas letras, números, hífens e sublinhados",
"es": "El nombre del espacio de trabajo solo puede contener letras, números, guiones y guiones bajos",
"ar": "يمكن أن يحتوي اسم مساحة العمل على أحرف وأرقام وشرطات وشرطات سفلية فقط",
"fr": "Le nom de l'espace de travail ne peut contenir que des lettres, des chiffres, des tirets et des traits de soulignement",
"tr": "Çalışma alanı adı yalnızca harfler, sayılar, tire ve alt çizgi içerebilir",
"de": "Der Arbeitsbereichsname darf nur Buchstaben, Zahlen, Bindestriche und Unterstriche enthalten",
"uk": "Назва робочого простору може містити тільки літери, цифри, дефіси та підкреслення"
},
"PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR": {
"en": "Webhook secret cannot contain spaces",
"ja": "Webhookシークレットにはスペースを含めることはできません",
"zh-CN": "Webhook密钥不能包含空格",
"zh-TW": "Webhook密鑰不能包含空格",
"ko-KR": "웹훅 시크릿에는 공백을 포함할 수 없습니다",
"no": "Webhook-hemmelighet kan ikke inneholde mellomrom",
"it": "Il segreto del webhook non può contenere spazi",
"pt": "O segredo do webhook não pode conter espaços",
"es": "El secreto del webhook no puede contener espacios",
"ar": "لا يمكن أن يحتوي سر الويب هوك على مسافات",
"fr": "Le secret du webhook ne peut pas contenir d'espaces",
"tr": "Webhook sırrı boşluk içeremez",
"de": "Das Webhook-Geheimnis darf keine Leerzeichen enthalten",
"uk": "Секрет веб-хука не може містити пробіли"
},
"PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR": {
"en": "Please enter a valid email address",
"ja": "有効なメールアドレスを入力してください",
"zh-CN": "请输入有效的电子邮件地址",
"zh-TW": "請輸入有效的電子郵件地址",
"ko-KR": "유효한 이메일 주소를 입력하세요",
"no": "Vennligst skriv inn en gyldig e-postadresse",
"it": "Inserisci un indirizzo email valido",
"pt": "Por favor, insira um endereço de email válido",
"es": "Por favor, introduce una dirección de correo electrónico válida",
"ar": "يرجى إدخال عنوان بريد إلكتروني صحيح",
"fr": "Veuillez saisir une adresse e-mail valide",
"tr": "Lütfen geçerli bir e-posta adresi girin",
"de": "Bitte geben Sie eine gültige E-Mail-Adresse ein",
"uk": "Будь ласка, введіть дійсну адресу електронної пошти"
},
"PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR": {
"en": "API key cannot contain spaces",
"ja": "APIキーにはスペースを含めることはできません",
"zh-CN": "API密钥不能包含空格",
"zh-TW": "API密鑰不能包含空格",
"ko-KR": "API 키에는 공백을 포함할 수 없습니다",
"no": "API-nøkkel kan ikke inneholde mellomrom",
"it": "La chiave API non può contenere spazi",
"pt": "A chave da API não pode conter espaços",
"es": "La clave API no puede contener espacios",
"ar": "لا يمكن أن يحتوي مفتاح API على مسافات",
"fr": "La clé API ne peut pas contenir d'espaces",
"tr": "API anahtarı boşluk içeremez",
"de": "Der API-Schlüssel darf keine Leerzeichen enthalten",
"uk": "Ключ API не може містити пробіли"
},
"MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT": {
"en": "Error loading microagent content.",
"ja": "マイクロエージェントのコンテンツの読み込み中にエラーが発生しました。",
"zh-CN": "加载微代理内容时出错。",
"zh-TW": "載入微代理內容時發生錯誤。",
"ko-KR": "마이크로에이전트 콘텐츠를 불러오는 중 오류가 발생했습니다.",
"no": "Feil ved lasting av mikroagent-innhold.",
"it": "Errore durante il caricamento del contenuto del microagente.",
"pt": "Erro ao carregar o conteúdo do microagente.",
"es": "Error al cargar el contenido del microagente.",
"ar": "حدث خطأ أثناء تحميل محتوى الوكيل الدقيق.",
"fr": "Erreur lors du chargement du contenu du microagent.",
"tr": "Mikro ajan içeriği yüklenirken hata oluştu.",
"de": "Fehler beim Laden des Microagent-Inhalts.",
"uk": "Помилка під час завантаження вмісту мікроагента."
}
}

View File

@@ -186,7 +186,14 @@ export const handlers = [
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: mockSaas,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
// Uncomment the following to test the maintenance banner
// MAINTENANCE: {
// startTime: "2024-01-15T10:00:00-05:00", // EST timestamp
// },
};
return HttpResponse.json(config);

View File

@@ -8,7 +8,7 @@ import { Controls } from "#/components/features/controls/controls";
import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import { clearJupyter } from "#/state/jupyter-slice";
import { useBatchFeedback } from "#/hooks/query/use-batch-feedback";
import { ChatInterface } from "../components/features/chat/chat-interface";
import { WsClientProvider } from "#/context/ws-client-provider";
import { EventHandler } from "../wrapper/event-handler";
@@ -37,6 +37,9 @@ function AppContent() {
const { data: isAuthed } = useIsAuthed();
const { providers } = useUserProviders();
// Fetch batch feedback data when conversation is loaded
useBatchFeedback();
const dispatch = useDispatch();
const navigate = useNavigate();

View File

@@ -18,6 +18,7 @@ import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
import { useUserProviders } from "#/hooks/use-user-providers";
import { ProjectManagementIntegration } from "#/components/features/settings/project-management/project-management-integration";
function GitSettingsScreen() {
const { t } = useTranslation();
@@ -110,6 +111,10 @@ function GitSettingsScreen() {
!gitlabHostInputHasValue &&
!bitbucketHostInputHasValue;
const shouldRenderExternalConfigureButtons = isSaas && config.APP_SLUG;
const shouldRenderProjectManagementIntegrations =
config?.FEATURE_FLAGS?.ENABLE_JIRA ||
config?.FEATURE_FLAGS?.ENABLE_JIRA_DC ||
config?.FEATURE_FLAGS?.ENABLE_LINEAR;
return (
<form
@@ -118,13 +123,35 @@ function GitSettingsScreen() {
className="flex flex-col h-full justify-between"
>
{!isLoading && (
<div className="p-9 flex flex-col gap-12">
<div className="p-9 flex flex-col">
{shouldRenderExternalConfigureButtons && !isLoading && (
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
<>
<div className="pb-1 flex flex-col">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$GITHUB)}
</h3>
<ConfigureGitHubRepositoriesAnchor slug={config.APP_SLUG!} />
</div>
<div className="w-1/2 border-b border-gray-200" />
</>
)}
{shouldRenderExternalConfigureButtons && !isLoading && (
<InstallSlackAppAnchor />
<>
<div className="pb-1 mt-6 flex flex-col">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.SETTINGS$SLACK)}
</h3>
<InstallSlackAppAnchor />
</div>
<div className="w-1/2 border-b border-gray-200" />
</>
)}
{shouldRenderProjectManagementIntegrations && !isLoading && (
<div className="mt-6">
<ProjectManagementIntegration />
</div>
)}
{!isSaas && (

View File

@@ -26,6 +26,7 @@ import { useAutoLogin } from "#/hooks/use-auto-login";
import { useAuthCallback } from "#/hooks/use-auth-callback";
import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage";
import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard";
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
export function ErrorBoundary() {
const error = useRouteError();
@@ -205,6 +206,9 @@ export default function MainApp() {
id="root-outlet"
className="h-[calc(100%-50px)] md:h-full w-full relative overflow-auto"
>
{config.data?.MAINTENANCE && (
<MaintenanceBanner startTime={config.data.MAINTENANCE.startTime} />
)}
<EmailVerificationGuard>
<Outlet />
</EmailVerificationGuard>

View File

@@ -17,6 +17,6 @@ export enum AgentState {
export const RUNTIME_INACTIVE_STATES = [
AgentState.INIT,
AgentState.LOADING,
AgentState.STOPPED,
// Removed AgentState.STOPPED to allow tabs to remain visible when agent is stopped
AgentState.ERROR,
];

View File

@@ -64,7 +64,6 @@ export interface FinishAction extends OpenHandsActionEvent<"finish"> {
source: "agent";
args: {
final_thought: string;
task_completed: "success" | "failure" | "partial";
outputs: Record<string, unknown>;
thought: string;
};

View File

@@ -4,11 +4,6 @@ export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
inputs: string[];
tools: string[];
created_at: string;
git_provider: string;
path: string;

View File

@@ -2,6 +2,7 @@ export const ProviderOptions = {
github: "github",
gitlab: "gitlab",
bitbucket: "bitbucket",
enterprise_sso: "enterprise_sso",
} as const;
export type Provider = keyof typeof ProviderOptions;

View File

@@ -8,6 +8,7 @@ export enum LoginMethod {
GITHUB = "github",
GITLAB = "gitlab",
BITBUCKET = "bitbucket",
ENTERPRISE_SSO = "enterprise_sso",
}
/**

View File

@@ -207,3 +207,22 @@ export const constructMicroagentUrl = (
return "";
}
};
/**
* Extract repository owner, repo name, and file path from repository and microagent data
* @param selectedRepository The selected repository object with full_name property
* @param microagent The microagent object with path property
* @returns Object containing owner, repo, and filePath
*
* @example
* const { owner, repo, filePath } = extractRepositoryInfo(selectedRepository, microagent);
*/
export const extractRepositoryInfo = (
selectedRepository: { full_name?: string } | null | undefined,
microagent: { path?: string } | null | undefined,
) => {
const [owner, repo] = selectedRepository?.full_name?.split("/") || [];
const filePath = microagent?.path || "";
return { owner, repo, filePath };
};

View File

@@ -21,6 +21,7 @@ export const VERIFIED_MODELS = [
"devstral-small-2507",
"devstral-medium-2507",
"kimi-k2-0711-preview",
"qwen3-coder-480b",
];
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
@@ -68,6 +69,7 @@ export const VERIFIED_OPENHANDS_MODELS = [
"devstral-medium-2507",
"devstral-small-2505",
"kimi-k2-0711-preview",
"qwen3-coder-480b",
];
// Default model for OpenHands provider

View File

@@ -43,7 +43,7 @@ describe("RepositorySelectionForm", () => {
});
(useCreateConversation as any).mockReturnValue({
mutate: vi.fn(),
mutate: vi.fn(() => (useIsCreatingConversation as any).mockReturnValue(true)),
isPending: false,
isSuccess: false,
});

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