Compare commits

..

14 Commits

Author SHA1 Message Date
Xingyao Wang
8067ae85c3 Merge branch 'main' into fix-git-coauthorship-cli-runtime 2025-08-22 09:41:09 -04:00
openhands
4e300b24b7 fix(runtime): ensure git safe.directory is configured for root user
When running DockerRuntime with run_as_openhands=False (i.e., as root),
the git safe.directory configuration was not being set up, causing
'dubious ownership' errors when git commands were executed.

This fix extracts the git configuration logic into a separate function
and ensures it's called for both root and non-root users, preventing
the 'fatal: detected dubious ownership in repository' error.

Fixes tests/runtime/test_bash.py::test_bash_remove_prefix[DockerRuntime-False]

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-19 14:51:08 +00:00
openhands
6ceae397d7 tests(runtime): align timeout assertion and robust git remote setup in test_bash\n\n- get_timeout_suffix(): assert on stable prefix only\n- test_bash_remove_prefix: tolerate existing origin via add-or-set-url\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-19 02:46:34 +00:00
openhands
f894c25597 runtime(docker/cli): fix git co-authorship + git safe.directory and workspace ownership
- Ensure workspace ownership and permissions are set after (or alongside) user creation
- Add defensive guard for UID=0 for non-root users (use 1000)
- Configure git safe.directory for /workspace to avoid ‘dubious ownership’ errors
- Set global core.hooksPath and init.templateDir to /openhands/git-hooks
- Ship prepare-commit-msg hook at runtime (copy from code or generate fallback) to always append
  ‘Co-authored-by: openhands <openhands@all-hands.dev>’
- BashSession: start shell in correct working dir for target user
- Command startup: never pass UID 0 to openhands user

This fixes tests/runtime/test_bash.py::test_git_co_authorship_runtime_setup for DockerRuntime and CLIRuntime.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 23:19:34 +00:00
openhands
87b936b04a bash: normalize timeout suffix format to 1 decimal for hard timeouts
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 21:51:05 +00:00
openhands
6068e4298b cli: ensure git co-authorship works in CLIRuntime
- Always prefix PATH for subprocess to use wrapper
- Configure global prepare-commit-msg hook for fallback

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-18 21:21:53 +00:00
openhands
388e3ba496 Revert "Fix git config tests by ensuring local module imports"
This reverts commit 2fd68cef2f.
2025-08-15 22:01:28 +00:00
openhands
2fd68cef2f Fix git config tests by ensuring local module imports
The tests were failing because the poetry environment was using an
installed version of the package from /openhands/code/ instead of the
local development version. This caused the tests to use the old version
of get_action_execution_server_startup_command that didn't include git
configuration arguments.

Fixed by adding the current directory to sys.path at the beginning of
the test file to ensure local modules are imported first.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-15 21:46:04 +00:00
openhands
e1788a74c5 Fix Docker build: Move git hook setup to separate RUN command
- Move git hook setup after source code is copied
- Separate RUN command prevents build failure when trying to copy hooks before source exists
- Git hooks are now set up after COPY ./code/openhands command
- This ensures the prepare-commit-msg file exists before trying to copy it

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 22:15:35 +00:00
Xingyao Wang
f046982d41 Update openhands/runtime/impl/cli/cli_runtime.py 2025-08-14 06:14:09 +08:00
openhands
e600225f0f Move CLI git wrapper to ~/.openhands/bin
- Use ~/.openhands/bin instead of workspace .openhands_bin directory
- This follows standard user binary patterns and persists across workspaces
- Avoids cluttering workspace with runtime infrastructure
- Updated test to check new location

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 22:05:06 +00:00
openhands
dd8401cc98 Update test to expect co-authorship for all runtimes
All runtimes have git hooks installed via Dockerfile.j2, so they should all
automatically add co-authorship. CLI runtime has additional PATH-based wrapper
but the base hook functionality works universally.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 22:03:02 +00:00
openhands
e99f41372a Fix test to not manually set up git hooks
- Remove manual git hook setup from test - runtime should handle this
- Rename test to reflect it tests runtime setup, not just hooks
- Make test work with different runtime types (CLI uses wrapper, others may differ)
- Test should verify runtime's co-authorship mechanism, not set it up

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 21:59:49 +00:00
openhands
e43f73f643 Implement automatic git co-authorship in CLI runtime using PATH-based wrapper
- Replace conditional environment variable check with always-enabled git co-authorship
- Use PATH manipulation instead of command wrapping to handle chained commands
- Create modified git wrapper that uses full path to real git executable to avoid recursion
- Update tests to reflect always-enabled behavior
- Add comprehensive documentation for git hooks and wrapper functionality

Fixes https://github.com/All-Hands-AI/OpenHands/issues/9957

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-13 21:51:38 +00:00
271 changed files with 6388 additions and 13377 deletions

View File

@@ -187,7 +187,6 @@ jobs:
test_settings.py::test_github_token_configuration \
test_conversation.py::test_conversation_start \
test_browsing_catchphrase.py::test_browsing_catchphrase \
test_multi_conversation_resume.py::test_multi_conversation_resume \
-v --no-header --capture=no --timeout=900
- name: Upload test results

View File

@@ -225,7 +225,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=false \
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"
@@ -284,7 +284,7 @@ jobs:
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
TEST_IN_CI=true \
RUN_AS_OPENHANDS=true \
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
env:
DEBUG: "1"

View File

@@ -15,7 +15,7 @@ jobs:
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
days-before-stale: 40
exempt-issue-labels: roadmap,backlog
exempt-issue-labels: 'roadmap'
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
days-before-close: 10

2
.gitignore vendored
View File

@@ -257,5 +257,5 @@ containers/runtime/code
# test results
test-results
.sessions
.eval_sessions

47
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,47 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/)
- id: end-of-file-fixer
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/)
- id: check-yaml
args: ["--allow-multiple-documents"]
- id: debug-statements
- repo: https://github.com/tox-dev/pyproject-fmt
rev: v2.5.1
hooks:
- id: pyproject-fmt
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.11.8
hooks:
# Run the linter.
- id: ruff
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
exclude: third_party/
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
exclude: third_party/
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy
additional_dependencies:
[types-requests, types-setuptools, types-pyyaml, types-toml, types-docker, types-Markdown, pydantic, lxml]
# To see gaps add `--html-report mypy-report/`
entry: mypy --config-file dev_config/python/mypy.ini openhands/
always_run: true
pass_filenames: false

View File

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

View File

@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
You can also run OpenHands directly with Docker:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
</details>
@@ -130,6 +130,7 @@ If you want to modify the OpenHands source code, check out [Development.md](http
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
## 📖 Documentation
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="Autogenerated Documentation by DeepWiki"></a>
To learn more about the project, and for tips on using OpenHands,
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
> **注意**: 如果您在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.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

View File

@@ -363,11 +363,10 @@ classpath = "my_package.my_module.MyCustomAgent"
#confirmation_mode = false
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
# Available options: 'llm' (default), 'invariant'
#security_analyzer = "llm"
#security_analyzer = ""
# Whether to enable security analyzer
#enable_security_analyzer = true
#enable_security_analyzer = false
#################################### Condenser #################################
# Condensers control how conversation history is managed and compressed when

View File

@@ -58,34 +58,34 @@ 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 --gid $OPENHANDS_USER_ID 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 openhands openhands && \
usermod -aG app openhands && \
usermod -aG sudo openhands && \
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
RUN chown -R openhands:openhands /app && chmod -R 770 /app
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
RUN chown -R openhands:app /app && chmod -R 770 /app
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
USER openhands
ENV VIRTUAL_ENV=/app/.venv \
PATH="/app/.venv/bin:$PATH" \
PYTHONPATH='/app'
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
RUN python openhands/core/download.py # No-op to download assets
# Add this line to set group ownership of all files/directories not already in "app" group
# openhands:openhands -> openhands:openhands
RUN find /app \! -group openhands -exec chgrp openhands {} +
# openhands:openhands -> openhands:app
RUN find /app \! -group app -exec chgrp app {} +
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
USER root

View File

@@ -54,7 +54,7 @@ else
fi
fi
fi
usermod -aG openhands enduser
usermod -aG app enduser
# get the user group of /var/run/docker.sock and set openhands to that group
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
echo "Docker socket group id: $DOCKER_SOCKET_GID"

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.55-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.54-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.55-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.54-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

@@ -1,5 +1,5 @@
---
title: Jira Data Center Integration (Coming soon...)
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.
---

View File

@@ -1,5 +1,5 @@
---
title: Jira Cloud Integration (Coming soon...)
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.
---

View File

@@ -1,5 +1,5 @@
---
title: Linear Integration (Coming soon...)
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.
---

View File

@@ -1,5 +1,5 @@
---
title: Project Management Tool Integrations (Coming soon...)
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.
---
@@ -18,9 +18,9 @@ Integration requires two levels of setup:
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 (Coming soon...)](./jira-integration.md)
- [Jira Data Center Integration (Coming soon...)](./jira-dc-integration.md)
- [Linear Integration (Coming soon...)](./linear-integration.md)
- [Jira Cloud Integration](./jira-integration.md)
- [Jira Data Center Integration](./jira-dc-integration.md)
- [Linear Integration](./linear-integration.md)
## Usage

View File

@@ -1,52 +0,0 @@
# Confirmation Mode and Security Analyzers
OpenHands provides a security framework to help protect users from potentially risky actions through **Confirmation Mode** and **Security Analyzers**. This system analyzes agent actions and prompts users for confirmation when high-risk operations are detected.
## Overview
The security system consists of two main components:
1. **Confirmation Mode**: When enabled, the agent will pause and ask for user confirmation before executing actions that are flagged as high-risk by the security analyzer.
2. **Security Analyzers**: These are modules that evaluate the risk level of agent actions and determine whether user confirmation is required.
## Configuration
### CLI
In CLI mode, confirmation is enabled by default. You will have an option to uses the LLM Analyzer and will automatically confirm LOW and MEDIUM risk actions, only prompting for HIGH risk actions.
## Security Analyzers
OpenHands includes multiple analyzers:
- **No Analyzer**: Do not use any security analyzer. The agent will prompt you to confirm *EVERY* action.
- **LLM Risk Analyzer** (default): Uses the same LLM as the agent to assess action risk levels
- **Invariant Analyzer**: Uses Invariant Labs' policy engine to evaluate action traces against security policies
### LLM Risk Analyzer
The default analyzer that leverages the agent's LLM to evaluate the security risk of each action. It considers the action type, parameters, and context to assign risk levels.
### Invariant Analyzer
An advanced analyzer that:
- Collects conversation events and parses them into a trace
- Checks the trace against an Invariant policy to classify risk (low, medium, high)
- Manages an Invariant server container automatically if needed
- Supports optional browsing-alignment and harmful-content checks
## How It Works
1. **Action Analysis**: When the agent wants to perform an action, the selected security analyzer evaluates its risk level.
2. **Risk Assessment**: The analyzer returns one of three risk levels:
- **LOW**: Action proceeds without confirmation
- **MEDIUM**: Action proceeds without confirmation (may be configurable in future)
- **HIGH**: Action is paused, and user confirmation is requested
3. **User Confirmation**: For high-risk actions, a confirmation dialog appears with:
- Description of the action
- Risk assessment explanation
- Options to approve or deny action
4. **Action Execution**: Based on user response:
- **Approve**: Action proceeds as planned
- **Deny**: Action is cancelled

View File

@@ -87,13 +87,19 @@ source ~/.bashrc # or source ~/.zshrc
</AccordionGroup>
3. Launch an interactive OpenHands conversation from the command line:
```bash
# If using uvx (recommended)
uvx --python 3.12 --from openhands-ai openhands
```
<Note>
If you have cloned the repository, you can also run the CLI directly using Poetry:
poetry run openhands
</Note>
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
The first time you run the CLI, it will take you through configuring the required LLM
@@ -113,7 +119,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.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -122,7 +128,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.55 \
docker.all-hands.dev/all-hands-ai/openhands:0.54 \
python -m openhands.cli.entry --override-cli-mode true
```

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

View File

@@ -45,13 +45,6 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
**Ubuntu (Linux Distribution)**
1. Install Ubuntu: `wsl --install -d Ubuntu` in PowerShell as Administrator.
2. Restart computer when prompted.
3. Open Ubuntu from Start menu to complete setup.
4. Verify installation: `wsl --list` should show Ubuntu.
**Docker Desktop**
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
@@ -60,7 +53,7 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
<Note>
The docker command below to start the app must be run inside the WSL terminal. Use `wsl -d Ubuntu` in PowerShell or search "Ubuntu" in the Start menu to access the Ubuntu terminal.
The docker command below to start the app must be run inside the WSL terminal.
</Note>
**Alternative: Windows without WSL**
@@ -116,17 +109,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
<Accordion title="Docker Command (Click to expand)">
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.54-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.54-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.55
docker.all-hands.dev/all-hands-ai/openhands:0.54
```
</Accordion>

View File

@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
directory, and it's called `openhands`.
directory. and it's called `openhands`.
## Debugging

View File

@@ -38,16 +38,6 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### On Linux, Getting ConnectTimeout Error
**Description**
When running on Linux, you might run into the error `ERROR:root:<class 'httpx.ConnectTimeout'>: timed out`.
**Resolution**
* Add the `--network host` to the docker run command.
### Internal Server Error. Ports are not available
**Description**

View File

@@ -13,7 +13,6 @@ N_RUNS=${4:-1}
export EXP_NAME=$EXP_NAME
# use 2x resources for rollout since some codebases are pretty resource-intensive
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
export ITERATIVE_EVAL_MODE=false
echo "MODEL: $MODEL"
echo "EXP_NAME: $EXP_NAME"
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset

View File

@@ -1,209 +0,0 @@
#!/usr/bin/env python3
"""
Script to aggregate token usage metrics from LLM completion files.
Usage:
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
Arguments:
directory_path: Path to the directory containing completion files
--input-cost: Cost per input token (default: 0.0)
--output-cost: Cost per output token (default: 0.0)
--cached-cost: Cost per cached token (default: 0.0)
"""
import argparse
import json
import os
from pathlib import Path
def aggregate_token_usage(
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
):
"""
Aggregate token usage metrics from all JSON completion files in the directory.
Args:
directory_path (str): Path to directory containing completion files
input_cost (float): Cost per input token
output_cost (float): Cost per output token
cached_cost (float): Cost per cached token
"""
# Initialize counters
totals = {
'input_tokens': 0,
'output_tokens': 0,
'cached_tokens': 0,
'total_tokens': 0,
'files_processed': 0,
'files_with_errors': 0,
'cost': 0,
}
# Find all JSON files recursively
json_files = list(Path(directory_path).rglob('*.json'))
print(f'Found {len(json_files)} JSON files to process...')
for json_file in json_files:
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Look for usage data in response or fncall_response
usage_data = None
if (
'response' in data
and isinstance(data['response'], dict)
and 'usage' in data['response']
):
usage_data = data['response']['usage']
elif (
'fncall_response' in data
and isinstance(data['fncall_response'], dict)
and 'usage' in data['fncall_response']
):
usage_data = data['fncall_response']['usage']
if usage_data:
# Extract token counts
completion_tokens = usage_data.get('completion_tokens', 0)
prompt_tokens = usage_data.get('prompt_tokens', 0)
cached_tokens = usage_data.get('cached_tokens', 0)
# Handle cases where cached_tokens might be in prompt_tokens_details
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
details = usage_data['prompt_tokens_details']
if isinstance(details, dict) and 'cached_tokens' in details:
cached_tokens = details.get('cached_tokens', 0) or 0
# Calculate non-cached input tokens
non_cached_input = prompt_tokens - cached_tokens
# Update totals
totals['input_tokens'] += non_cached_input
totals['output_tokens'] += completion_tokens
totals['cached_tokens'] += cached_tokens
totals['total_tokens'] += prompt_tokens + completion_tokens
if 'cost' in data:
totals['cost'] += data['cost']
totals['files_processed'] += 1
# Progress indicator
if totals['files_processed'] % 1000 == 0:
print(f'Processed {totals["files_processed"]} files...')
except Exception as e:
totals['files_with_errors'] += 1
if totals['files_with_errors'] <= 5: # Only show first 5 errors
print(f'Error processing {json_file}: {e}')
# Calculate costs
input_cost_total = totals['input_tokens'] * input_cost
output_cost_total = totals['output_tokens'] * output_cost
cached_cost_total = totals['cached_tokens'] * cached_cost
total_cost = input_cost_total + output_cost_total + cached_cost_total
# Print results
print('\n' + '=' * 60)
print('TOKEN USAGE AGGREGATION RESULTS')
print('=' * 60)
print(f'Files processed: {totals["files_processed"]:,}')
print(f'Files with errors: {totals["files_with_errors"]:,}')
print()
print('TOKEN COUNTS:')
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
print(f' Output tokens: {totals["output_tokens"]:,}')
print(f' Cached tokens: {totals["cached_tokens"]:,}')
print(f' Total tokens: {totals["total_tokens"]:,}')
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
print()
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
print('COST CALCULATED BASED ON PROVIDED RATE:')
print(
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
)
print(
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
)
print(
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
)
print(f' Total cost: ${total_cost:.6f}')
print()
print('SUMMARY:')
print(
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
)
print(f' Total output tokens: {totals["output_tokens"]:,}')
print(f' Grand total tokens: {totals["total_tokens"]:,}')
return totals
def main():
parser = argparse.ArgumentParser(
description='Aggregate token usage metrics from LLM completion files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python aggregate_token_usage.py /path/to/completions
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
""",
)
parser.add_argument(
'directory_path', help='Path to directory containing completion files'
)
parser.add_argument(
'--input-cost',
type=float,
default=0.0,
help='Cost per input token (default: 0.0)',
)
parser.add_argument(
'--output-cost',
type=float,
default=0.0,
help='Cost per output token (default: 0.0)',
)
parser.add_argument(
'--cached-cost',
type=float,
default=0.0,
help='Cost per cached token (default: 0.0)',
)
args = parser.parse_args()
# Validate directory path
if not os.path.exists(args.directory_path):
print(f"Error: Directory '{args.directory_path}' does not exist.")
return 1
if not os.path.isdir(args.directory_path):
print(f"Error: '{args.directory_path}' is not a directory.")
return 1
# Run aggregation
try:
aggregate_token_usage(
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
)
return 0
except Exception as e:
print(f'Error during aggregation: {e}')
return 1
if __name__ == '__main__':
exit(main())

View File

@@ -54,14 +54,12 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
full_name: "rbren/polaris",
git_provider: "github",
is_public: true,
main_branch: "main",
},
{
id: "2",
full_name: "All-Hands-AI/OpenHands",
git_provider: "github",
is_public: true,
main_branch: "main",
},
];
@@ -101,15 +99,16 @@ describe("RepoConnector", () => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then interact with the repository dropdown
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -135,23 +134,23 @@ describe("RepoConnector", () => {
expect(launchButton).toBeDisabled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -162,8 +161,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
expect(launchButton).toBeEnabled();
@@ -226,19 +224,6 @@ describe("RepoConnector", () => {
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "mock-conversation-id",
title: "Test Conversation",
selected_repository: "user/repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "STARTING",
runtime_status: null,
url: null,
session_api_key: null,
});
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
@@ -259,23 +244,23 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
within(repoConnector).getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -286,8 +271,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
@@ -304,8 +288,6 @@ describe("RepoConnector", () => {
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
@@ -316,10 +298,10 @@ describe("RepoConnector", () => {
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
]);
renderRepoConnector();
@@ -327,16 +309,16 @@ describe("RepoConnector", () => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -347,8 +329,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
@@ -377,7 +358,7 @@ describe("RepoConnector", () => {
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
const dropdown = screen.queryByTestId("git-repo-dropdown");
const dropdown = screen.queryByTestId("repo-dropdown");
const launchButton = screen.queryByTestId("repo-launch-button");
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);

View File

@@ -151,7 +151,7 @@ describe("RepositorySelectionForm", () => {
});
renderForm();
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
@@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
renderForm();
expect(
await screen.findByTestId("dropdown-error"),
await screen.findByTestId("repo-dropdown-error"),
).toBeInTheDocument();
expect(
screen.getByText("Failed to load data"),
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
@@ -231,7 +231,11 @@ describe("RepositorySelectionForm", () => {
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector(
'input[type="text"]',
) as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
@@ -266,7 +270,11 @@ describe("RepositorySelectionForm", () => {
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector(
'input[type="text"]',
) as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(

View File

@@ -37,27 +37,34 @@ const selectRepository = async (repoName: string) => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const repoInput = within(dropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
const options = screen.getAllByText(repoName);
// Find the option in the dropdown (it will have role="option")
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
expect(dropdownOption).toBeInTheDocument();
});
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
await userEvent.click(within(dropdownMenu).getByText(repoName));
const options = screen.getAllByText(repoName);
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
await userEvent.click(dropdownOption!);
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
};
@@ -78,14 +85,12 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
full_name: "octocat/hello-world",
git_provider: "github",
is_public: true,
main_branch: "main",
},
{
id: "2",
full_name: "octocat/earth",
git_provider: "github",
is_public: true,
main_branch: "main",
},
];
@@ -135,10 +140,10 @@ describe("HomeScreen", () => {
await screen.findAllByTestId("task-launch-button");
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
]);
// Select a repository to enable the repo launch button
await selectRepository("octocat/hello-world");

View File

@@ -79,35 +79,6 @@ describe("Content", () => {
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
it("should conditionally show security analyzer based on confirmation mode", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
// Initially confirmation mode is false, so security analyzer should not be visible
expect(confirmation).not.toBeChecked();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
// Enable confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// Security analyzer should now be visible
screen.getByTestId("security-analyzer-input");
// Disable confirmation mode again
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
// Security analyzer should be hidden again
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
});
});
describe("Advanced form", () => {
@@ -136,6 +107,7 @@ describe("Content", () => {
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor-advanced");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
@@ -158,6 +130,9 @@ describe("Content", () => {
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");
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
@@ -165,7 +140,15 @@ describe("Content", () => {
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
@@ -194,7 +177,7 @@ describe("Content", () => {
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "none",
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
@@ -220,7 +203,7 @@ describe("Content", () => {
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
@@ -310,7 +293,7 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
@@ -323,7 +306,7 @@ describe("Form submission", () => {
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: null,
security_analyzer: "mock-invariant",
}),
);
});
@@ -392,10 +375,8 @@ describe("Form submission", () => {
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 condensor = await screen.findByTestId("enable-memory-condenser-switch");
// Confirmation mode switch is now in basic settings, always visible
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");
@@ -470,17 +451,14 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
// revert back to original value
await userEvent.click(securityAnalyzer);
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
await userEvent.click(originalSecurityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
@@ -574,7 +552,7 @@ describe("Form submission", () => {
expect.objectContaining({
llm_model: "openhands/claude-sonnet-4-20250514",
llm_base_url: "",
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
confirmation_mode: false,
}),
);
});

View File

@@ -107,7 +107,9 @@ describe("Content", () => {
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
expect(button).toHaveAttribute("href", "/settings/integrations");
await userEvent.click(button);
screen.getByTestId("git-settings-screen");
});
it("should render an empty table when there are no existing secrets", async () => {

View File

@@ -29,5 +29,23 @@ describe("hasAdvancedSettingsSet", () => {
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
CONFIRMATION_MODE: true,
}),
).toBe(true);
});
test("SECURITY_ANALYZER is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
SECURITY_ANALYZER: "test",
}),
).toBe(true);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.55.0",
"version": "0.54.0",
"private": true,
"type": "module",
"engines": {
@@ -11,50 +11,50 @@
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@react-types/shared": "^3.32.0",
"@react-router/node": "^7.8.0",
"@react-router/serve": "^7.8.0",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.2",
"@stripe/stripe-js": "^7.9.0",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"downshift": "^9.0.10",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.12",
"i18next": "^25.4.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.30",
"jose": "^6.1.0",
"lucide-react": "^0.542.0",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.261.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.2",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.8.2",
"react-syntax-highlighter": "^15.6.6",
"react-router": "^7.8.0",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.1.3",
"vite": "^7.1.1",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
@@ -88,17 +88,17 @@
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.55.0",
"@react-router/dev": "^7.8.2",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.8.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/node": "^24.2.0",
"@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",
@@ -117,16 +117,16 @@
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.2.0",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.4",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.5.0",
"stripe": "^18.4.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"
},

View File

@@ -21,12 +21,7 @@ import {
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import {
GitUser,
GitRepository,
PaginatedBranchesResponse,
Branch,
} from "#/types/git";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
@@ -572,35 +567,11 @@ class OpenHands {
};
}
static async getRepositoryBranches(
repository: string,
page: number = 1,
perPage: number = 30,
): Promise<PaginatedBranchesResponse> {
const { data } = await openHands.get<PaginatedBranchesResponse>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
);
return data;
}
static async searchRepositoryBranches(
repository: string,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
): Promise<Branch[]> {
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
);
return data;
}
@@ -755,27 +726,6 @@ class OpenHands {
);
return data;
}
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default OpenHands;

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitBranchDropdownProps {
repositoryName?: string | null;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (branchName: string | null) => void;
}
export function GitBranchDropdown({
repositoryName,
value,
placeholder = "Select branch...",
className,
errorMessage,
disabled = false,
onChange,
}: GitBranchDropdownProps) {
const { data: branches, isLoading } = useRepositoryBranches(
repositoryName || null,
);
const options: SelectOption[] = useMemo(
() =>
branches?.map((branch) => ({
value: branch.name,
label: branch.name,
})) || [],
[branches],
);
const hasNoBranches = !isLoading && branches && branches.length === 0;
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value || null);
};
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
const displayErrorMessage = hasNoBranches
? "This repository has no branches"
: errorMessage;
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={displayPlaceholder}
className={className}
errorMessage={displayErrorMessage}
disabled={isDisabled}
isClearable={false}
isSearchable
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { Provider } from "../../types/settings";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitProviderDropdownProps {
providers: Provider[];
value?: Provider | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
isLoading?: boolean;
onChange?: (provider: Provider | null) => void;
}
export function GitProviderDropdown({
providers,
value,
placeholder = "Select Provider",
className,
errorMessage,
disabled = false,
isLoading = false,
onChange,
}: GitProviderDropdownProps) {
const options: SelectOption[] = useMemo(
() =>
providers.map((provider) => ({
value: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1),
})),
[providers],
);
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value as Provider | null);
};
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isSearchable={false}
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -0,0 +1,208 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../../types/settings";
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
import { useDebounce } from "../../hooks/use-debounce";
import OpenHands from "../../api/open-hands";
import { GitRepository } from "../../types/git";
import {
ReactSelectAsyncDropdown,
AsyncSelectOption,
} from "./react-select-async-dropdown";
export interface GitRepositoryDropdownProps {
provider: Provider;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (repository?: GitRepository) => void;
}
export function GitRepositoryDropdown({
provider,
value,
placeholder = "Search repositories...",
className,
errorMessage,
disabled = false,
onChange,
}: GitRepositoryDropdownProps) {
const { t } = useTranslation();
const [searchInput, setSearchInput] = useState("");
const debouncedSearchInput = useDebounce(searchInput, 300);
// Process search input to handle URLs
const processedSearchInput = useMemo(() => {
if (debouncedSearchInput.startsWith("https://")) {
const match = debouncedSearchInput.match(
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
);
return match ? match[1] : debouncedSearchInput;
}
return debouncedSearchInput;
}, [debouncedSearchInput]);
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
} = useGitRepositories({
provider,
enabled: !disabled,
});
// Search query for processed input (handles URLs)
const { data: searchData, isLoading: isSearchLoading } =
useSearchRepositories(processedSearchInput, provider);
const allOptions: AsyncSelectOption[] = useMemo(
() =>
data?.pages
? data.pages.flatMap((page) =>
page.data.map((repo) => ({
value: repo.id,
label: repo.full_name,
})),
)
: [],
[data],
);
const searchOptions: AsyncSelectOption[] = useMemo(
() =>
searchData
? searchData.map((repo) => ({
value: repo.id,
label: repo.full_name,
}))
: [],
[searchData],
);
const selectedOption = useMemo(() => {
// First check in loaded pages
const option = allOptions.find((opt) => opt.value === value);
if (option) return option;
// If not found, check in search results
const searchOption = searchOptions.find((opt) => opt.value === value);
if (searchOption) return searchOption;
return null;
}, [allOptions, searchOptions, value]);
const loadOptions = useCallback(
async (inputValue: string): Promise<AsyncSelectOption[]> => {
// Update search input to trigger debounced search
setSearchInput(inputValue);
// If empty input, show all loaded options
if (!inputValue.trim()) {
return allOptions;
}
// For very short inputs, do local filtering
if (inputValue.length < 2) {
return allOptions.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
}
// Handle URL inputs by performing direct search
if (inputValue.startsWith("https://")) {
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
if (match) {
const repoName = match[1];
try {
// Perform direct search for URL-based inputs
const repositories = await OpenHands.searchGitRepositories(
repoName,
3,
provider,
);
return repositories.map((repo) => ({
value: repo.full_name,
label: repo.full_name,
data: repo,
}));
} catch (error) {
// Fall back to local filtering if search fails
return allOptions.filter((option) =>
option.label.toLowerCase().includes(repoName.toLowerCase()),
);
}
}
}
// For regular text inputs, use hook-based search results if available
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
return searchOptions;
}
// Fallback to local filtering while search is loading
return allOptions.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
},
[allOptions, searchOptions, processedSearchInput, provider],
);
const handleChange = (option: AsyncSelectOption | null) => {
if (!option) {
onChange?.(undefined);
return;
}
// First check in loaded pages
let repo = data?.pages
?.flatMap((p) => p.data)
.find((r) => r.id === option.value);
// If not found, check in search results
if (!repo) {
repo = searchData?.find((r) => r.id === option.value);
}
onChange?.(repo);
};
const handleMenuScrollToBottom = useCallback(() => {
if (hasNextPage && !isFetchingNextPage && !isLoading) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
return (
<>
<ReactSelectAsyncDropdown
testId="repo-dropdown"
loadOptions={loadOptions}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isLoading={isLoading || isFetchingNextPage || isSearchLoading}
cacheOptions
defaultOptions={allOptions}
onChange={handleChange}
onMenuScrollToBottom={handleMenuScrollToBottom}
/>
{isError && (
<div
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback, useMemo } from "react";
import AsyncSelect from "react-select/async";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type AsyncSelectOption = SelectOptionBase;
export interface ReactSelectAsyncDropdownProps {
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
testId?: string;
placeholder?: string;
value?: AsyncSelectOption | null;
defaultValue?: AsyncSelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isLoading?: boolean;
cacheOptions?: boolean;
defaultOptions?: boolean | AsyncSelectOption[];
onChange?: (option: AsyncSelectOption | null) => void;
onMenuScrollToBottom?: () => void;
}
export function ReactSelectAsyncDropdown({
loadOptions,
testId,
placeholder = "Search...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isLoading = false,
cacheOptions = true,
defaultOptions = true,
onChange,
onMenuScrollToBottom,
}: ReactSelectAsyncDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
const handleLoadOptions = useCallback(
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
loadOptions(inputValue)
.then((options) => callback(options))
.catch(() => callback([]));
},
[loadOptions],
);
return (
<div data-testid={testId} className={cn("w-full", className)}>
<AsyncSelect
loadOptions={handleLoadOptions}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isLoading={isLoading}
cacheOptions={cacheOptions}
defaultOptions={defaultOptions}
onChange={onChange}
onMenuScrollToBottom={onMenuScrollToBottom}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{errorMessage}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from "react";
import Select from "react-select";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type SelectOption = SelectOptionBase;
export interface ReactSelectDropdownProps {
options: SelectOption[];
placeholder?: string;
value?: SelectOption | null;
defaultValue?: SelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isSearchable?: boolean;
isLoading?: boolean;
onChange?: (option: SelectOption | null) => void;
}
export function ReactSelectDropdown({
options,
placeholder = "Select option...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isSearchable = true,
isLoading = false,
onChange,
}: ReactSelectDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
return (
<div className={cn("w-full", className)}>
<Select
options={options}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isSearchable={isSearchable}
isLoading={isLoading}
onChange={onChange}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { StylesConfig } from "react-select";
export interface SelectOptionBase {
value: string;
label: string;
}
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
T,
false
> => ({
control: (provided, state) => ({
...provided,
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
border: "1px solid #717888",
borderRadius: "0.125rem",
minHeight: "2.5rem",
padding: "0 0.5rem",
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
opacity: state.isDisabled ? 0.6 : 1,
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:hover": {
borderColor: "#717888",
},
}),
input: (provided) => ({
...provided,
color: "#ECEDEE", // content
}),
placeholder: (provided) => ({
...provided,
fontStyle: "italic",
color: "#B7BDC2", // tertiary-light
}),
singleValue: (provided, state) => ({
...provided,
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
}),
menu: (provided) => ({
...provided,
backgroundColor: "#454545", // tertiary
border: "1px solid #717888",
borderRadius: "0.75rem",
overflow: "hidden", // ensure menu items don't overflow rounded corners
}),
menuList: (provided) => ({
...provided,
padding: "0.25rem", // add some padding around menu items
}),
option: (provided, state) => {
let backgroundColor = "transparent";
if (state.isSelected) {
backgroundColor = "#C9B974"; // primary for selected
} else if (state.isFocused) {
backgroundColor = "#24272E"; // base-secondary for hover/focus
}
return {
...provided,
backgroundColor,
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
borderRadius: "0.5rem", // rounded menu items
margin: "0.125rem 0", // small gap between items
"&:hover": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
},
"&:active": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
color: state.isSelected ? "#000000" : "#ECEDEE",
},
};
},
clearIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
dropdownIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
loadingIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
}),
});

View File

@@ -9,7 +9,6 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
import { anchor } from "../markdown/anchor";
import { OpenHandsSourceType } from "#/types/core/base";
import { paragraph } from "../markdown/paragraph";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface ChatMessageProps {
type: OpenHandsSourceType;
@@ -17,7 +16,6 @@ interface ChatMessageProps {
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
@@ -68,35 +66,17 @@ export function ChatMessage({
"items-center gap-1",
)}
>
{actions?.map((action, index) =>
action.tooltip ? (
<TooltipButton
key={index}
tooltip={action.tooltip}
ariaLabel={action.tooltip}
placement="top"
>
<button
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
</TooltipButton>
) : (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
),
)}
{actions?.map((action, index) => (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
))}
<CopyToClipboardButton
isHidden={!isHovering}

View File

@@ -72,9 +72,6 @@ const getRecallObservationContent = (event: RecallObservation): string => {
if (event.extras.repo_instructions) {
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
}
if (event.extras.conversation_instructions) {
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
}
if (event.extras.additional_agent_instructions) {
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
}

View File

@@ -46,7 +46,6 @@ interface EventMessageProps {
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isInLast10Actions: boolean;
}

View File

@@ -1,5 +1,4 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
@@ -25,17 +24,6 @@ import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import MemoryIcon from "#/icons/memory_icon.svg?react";
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
isAwaitingUserConfirmation: boolean;
@@ -43,11 +31,8 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const {
createConversationAndSubscribe,
isPending,
unsubscribeFromConversation,
} = useCreateConversationAndSubscribeMultiple();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const { getOptimisticUserMessage } = useOptimisticUserMessage();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
@@ -63,8 +48,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
EventMicroagentStatus[]
>([]);
const { t } = useTranslation();
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
@@ -110,6 +93,20 @@ export const Messages: React.FC<MessagesProps> = React.memo(
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown, microagentConversationId: string) => {
// Handle error events
const isErrorEvent = (
evt: unknown,
): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
@@ -122,11 +119,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent)
) {
// Handle completion states
if (
socketEvent.extras.agent_state === AgentState.FINISHED ||
socketEvent.extras.agent_state === AgentState.AWAITING_USER_INPUT
) {
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
@@ -134,8 +127,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
: statusEntry,
),
);
unsubscribeFromConversation(microagentConversationId);
}
} else if (
isOpenHandsEvent(socketEvent) &&
@@ -156,27 +147,9 @@ export const Messages: React.FC<MessagesProps> = React.memo(
),
);
}
unsubscribeFromConversation(microagentConversationId);
} else {
// For any other event, transition from WAITING to CREATING if still waiting
setMicroagentStatuses((prev) => {
const currentStatus = prev.find(
(entry) => entry.conversationId === microagentConversationId,
)?.status;
if (currentStatus === MicroagentStatus.WAITING) {
return prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.CREATING }
: statusEntry,
);
}
return prev; // No change needed
});
}
},
[setMicroagentStatuses, unsubscribeFromConversation],
[setMicroagentStatuses],
);
const handleLaunchMicroagent = (
@@ -205,13 +178,13 @@ export const Messages: React.FC<MessagesProps> = React.memo(
},
onSuccessCallback: (newConversationId: string) => {
setShowLaunchMicroagentModal(false);
// Update status with conversation ID - start with WAITING
// Update status with conversation ID
setMicroagentStatuses((prev) => [
...prev.filter((status) => status.eventId !== selectedEventId),
{
eventId: selectedEventId,
conversationId: newConversationId,
status: MicroagentStatus.WAITING,
status: MicroagentStatus.CREATING,
},
]);
},
@@ -246,7 +219,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
},
]
: undefined

View File

@@ -76,10 +76,6 @@ export function LaunchMicroagentModal({
</button>
</div>
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
{t("MICROAGENT$DEFINITION")}
</span>
<form
data-testid="launch-microagent-modal"
onSubmit={onSubmit}

View File

@@ -19,8 +19,6 @@ export function MicroagentStatusIndicator({
const getStatusText = () => {
switch (status) {
case MicroagentStatus.WAITING:
return t("MICROAGENT$STATUS_WAITING");
case MicroagentStatus.CREATING:
return t("MICROAGENT$STATUS_CREATING");
case MicroagentStatus.COMPLETED:
@@ -37,8 +35,6 @@ export function MicroagentStatusIndicator({
const getStatusIcon = () => {
switch (status) {
case MicroagentStatus.WAITING:
return <Spinner size="sm" />;
case MicroagentStatus.CREATING:
return <Spinner size="sm" />;
case MicroagentStatus.COMPLETED:

View File

@@ -10,11 +10,6 @@ interface ConversationCreatedToastProps {
onClose: () => void;
}
interface ConversationStartingToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationCreatedToast({
conversationId,
onClose,
@@ -42,33 +37,6 @@ function ConversationCreatedToast({
);
}
function ConversationStartingToast({
conversationId,
onClose,
}: ConversationStartingToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<div>
{t("MICROAGENT$CONVERSATION_STARTING")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationFinishedToastProps {
conversationId: string;
onClose: () => void;
@@ -110,18 +78,10 @@ function ConversationErroredToast({
errorMessage,
onClose,
}: ConversationErroredToastProps) {
const { t } = useTranslation();
// Check if the error message is a translation key
const displayMessage =
errorMessage === "MICROAGENT$UNKNOWN_ERROR"
? t(errorMessage)
: errorMessage;
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="error" />
<div>{displayMessage}</div>
<div>{errorMessage}</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
@@ -176,18 +136,3 @@ export const renderConversationErroredToast = (
duration: 5000,
},
);
export const renderConversationStartingToast = (conversationId: string) =>
toast(
(toastInstance) => (
<ConversationStartingToast
conversationId={conversationId}
onClose={() => toast.dismiss(toastInstance.id)}
/>
),
{
...TOAST_OPTIONS,
id: `starting-${conversationId}`,
duration: 10000, // Show for 10 seconds or until dismissed
},
);

View File

@@ -7,10 +7,11 @@ import { ConversationCard } from "../conversation-panel/conversation-card";
import { Provider } from "#/types/settings";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
showSecurityLock: boolean;
}
export function Controls({ showSecurityLock }: ControlsProps) {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
@@ -20,7 +21,9 @@ export function Controls({ showSecurityLock }: ControlsProps) {
<AgentControlBar />
<AgentStatusBar />
{showSecurityLock && <SecurityLock />}
{showSecurityLock && (
<SecurityLock onClick={() => setSecurityOpen(true)} />
)}
</div>
<ConversationCard

View File

@@ -1,28 +1,17 @@
import { IoLockClosed } from "react-icons/io5";
import { Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { I18nKey } from "#/i18n/declaration";
export function SecurityLock() {
const { t } = useTranslation();
interface SecurityLockProps {
onClick: () => void;
}
export function SecurityLock({ onClick }: SecurityLockProps) {
return (
<Tooltip
content={
<div className="max-w-xs p-2">
{t(I18nKey.SETTINGS$CONFIRMATION_MODE_LOCK_TOOLTIP)}
</div>
}
placement="top"
<div
className="cursor-pointer hover:opacity-80 transition-all"
style={{ marginRight: "8px" }}
onClick={onClick}
>
<Link
to="/settings"
className="mr-2 cursor-pointer hover:opacity-80 transition-all"
aria-label={t(I18nKey.SETTINGS$TITLE)}
>
<IoLockClosed size={20} />
</Link>
</Tooltip>
<IoLockClosed size={20} />
</div>
);
}

View File

@@ -23,9 +23,9 @@ export function ConfirmStopModal({
<ModalBackdrop>
<ModalBody className="items-start border border-tertiary">
<div className="flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_PAUSE)} />
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
<BaseModalDescription
description={t(I18nKey.CONVERSATION$PAUSE_WARNING)}
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
/>
</div>
<div

View File

@@ -129,7 +129,7 @@ export function ConversationCardContextMenu({
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$PAUSE)} />
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
</ContextMenuListItem>
)}

View File

@@ -1,6 +1,4 @@
import { ConversationStatus } from "#/types/conversation-status";
import ArchivedIcon from "./state-indicators/archived.svg?react";
import ErrorIcon from "./state-indicators/error.svg?react";
import RunningIcon from "./state-indicators/running.svg?react";
import StartingIcon from "./state-indicators/starting.svg?react";
import StoppedIcon from "./state-indicators/stopped.svg?react";
@@ -11,8 +9,6 @@ const CONVERSATION_STATUS_INDICATORS: Record<ConversationStatus, SVGIcon> = {
STOPPED: StoppedIcon,
RUNNING: RunningIcon,
STARTING: StartingIcon,
ARCHIVED: ArchivedIcon,
ERROR: ErrorIcon,
};
interface ConversationStateIndicatorProps {

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#A7A9AC"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 7h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1 0 1.43-.98 2.63-2.31 2.98l1.46 1.46C20.88 15.61 22 13.95 22 12c0-2.76-2.24-5-5-5zm-1 4h-2.19l2 2H16zM2 4.27l3.11 3.11C3.29 8.12 2 9.91 2 12c0 2.76 2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1 0-1.59 1.21-2.9 2.76-3.07L8.73 11H8v2h2.73L13 15.27V17h1.73l4.01 4L20 19.74 3.27 3 2 4.27z"/><path d="M0 24V0" fill="none"/></svg>

Before

Width:  |  Height:  |  Size: 512 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e7000b"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>

Before

Width:  |  Height:  |  Size: 254 B

View File

@@ -1,86 +0,0 @@
import React from "react";
import {
UseComboboxGetMenuPropsOptions,
UseComboboxGetItemPropsOptions,
} from "downshift";
import { Branch } from "#/types/git";
import { DropdownItem } from "../shared/dropdown-item";
import { GenericDropdownMenu, EmptyState } from "../shared";
export interface BranchDropdownMenuProps {
isOpen: boolean;
filteredBranches: Branch[];
inputValue: string;
highlightedIndex: number;
selectedItem: Branch | null;
getMenuProps: <Options>(
options?: UseComboboxGetMenuPropsOptions & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<Branch> & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
menuRef: React.RefObject<HTMLUListElement | null>;
}
export function BranchDropdownMenu({
isOpen,
filteredBranches,
inputValue,
highlightedIndex,
selectedItem,
getMenuProps,
getItemProps,
onScroll,
menuRef,
}: BranchDropdownMenuProps) {
const renderItem = (
branch: Branch,
index: number,
currentHighlightedIndex: number,
currentSelectedItem: Branch | null,
currentGetItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<Branch> & Options,
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => (
<DropdownItem
key={branch.name}
item={branch}
index={index}
isHighlighted={currentHighlightedIndex === index}
isSelected={currentSelectedItem?.name === branch.name}
getItemProps={currentGetItemProps}
getDisplayText={(branchItem) => branchItem.name}
getItemKey={(branchItem) => branchItem.name}
/>
);
const renderEmptyState = (currentInputValue: string) => (
<li className="px-3 py-2">
<EmptyState
inputValue={currentInputValue}
searchMessage="No branches found"
emptyMessage="No branches available"
testId="git-branch-dropdown-empty"
/>
</li>
);
return (
<div data-testid="git-branch-dropdown-menu">
<GenericDropdownMenu
isOpen={isOpen}
filteredItems={filteredBranches}
inputValue={inputValue}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
onScroll={onScroll}
menuRef={menuRef}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
/>
</div>
);
}

View File

@@ -1,236 +0,0 @@
import React, {
useState,
useMemo,
useCallback,
useRef,
useEffect,
} from "react";
import { useCombobox } from "downshift";
import { Branch } from "#/types/git";
import { Provider } from "#/types/settings";
import { useDebounce } from "#/hooks/use-debounce";
import { cn } from "#/utils/utils";
import { useBranchData } from "#/hooks/query/use-branch-data";
import { LoadingSpinner } from "../shared/loading-spinner";
import { ClearButton } from "../shared/clear-button";
import { ToggleButton } from "../shared/toggle-button";
import { ErrorMessage } from "../shared/error-message";
import { BranchDropdownMenu } from "./branch-dropdown-menu";
export interface GitBranchDropdownProps {
repository: string | null;
provider: Provider;
selectedBranch: Branch | null;
onBranchSelect: (branch: Branch | null) => void;
defaultBranch?: string | null;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function GitBranchDropdown({
repository,
provider,
selectedBranch,
onBranchSelect,
defaultBranch,
placeholder = "Select branch...",
disabled = false,
className,
}: GitBranchDropdownProps) {
const [inputValue, setInputValue] = useState("");
const [userManuallyCleared, setUserManuallyCleared] = useState(false);
const debouncedInputValue = useDebounce(inputValue, 300);
const menuRef = useRef<HTMLUListElement>(null);
// Process search input (debounced and filtered)
const processedSearchInput = useMemo(
() =>
debouncedInputValue.trim().length > 0 ? debouncedInputValue.trim() : "",
[debouncedInputValue],
);
// Use the new branch data hook with default branch prioritization
const {
branches: filteredBranches,
isLoading,
isError,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isSearchLoading,
} = useBranchData(
repository,
provider,
defaultBranch || null,
processedSearchInput,
inputValue,
selectedBranch,
);
const error = isError ? new Error("Failed to load branches") : null;
// Handle clear
const handleClear = useCallback(() => {
setInputValue("");
onBranchSelect(null);
setUserManuallyCleared(true); // Mark that user manually cleared the branch
}, [onBranchSelect]);
// Handle branch selection
const handleBranchSelect = useCallback(
(branch: Branch | null) => {
onBranchSelect(branch);
setInputValue("");
},
[onBranchSelect],
);
// Handle input value change
const handleInputValueChange = useCallback(
({ inputValue: newInputValue }: { inputValue?: string }) => {
if (newInputValue !== undefined) {
setInputValue(newInputValue);
}
},
[],
);
// Handle menu scroll for infinite loading
const handleMenuScroll = useCallback(
(event: React.UIEvent<HTMLUListElement>) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
if (
scrollHeight - scrollTop <= clientHeight * 1.5 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
},
[hasNextPage, isFetchingNextPage, fetchNextPage],
);
// Downshift configuration
const {
isOpen,
selectedItem,
highlightedIndex,
getInputProps,
getItemProps,
getMenuProps,
getToggleButtonProps,
} = useCombobox({
items: filteredBranches,
selectedItem: selectedBranch,
itemToString: (item) => item?.name || "",
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
handleBranchSelect(newSelectedItem || null);
},
onInputValueChange: handleInputValueChange,
inputValue,
});
// Reset branch selection when repository changes
useEffect(() => {
if (repository) {
onBranchSelect(null);
setUserManuallyCleared(false); // Reset the manual clear flag when repository changes
}
}, [repository, onBranchSelect]);
// Auto-select default branch when branches are loaded and no branch is selected
// But only if the user hasn't manually cleared the branch
useEffect(() => {
if (
repository &&
defaultBranch &&
!selectedBranch &&
!userManuallyCleared && // Don't auto-select if user manually cleared
filteredBranches.length > 0 &&
!isLoading
) {
const defaultBranchObj = filteredBranches.find(
(branch) => branch.name === defaultBranch,
);
if (defaultBranchObj) {
onBranchSelect(defaultBranchObj);
}
}
}, [
repository,
defaultBranch,
selectedBranch,
userManuallyCleared,
filteredBranches,
onBranchSelect,
isLoading,
]);
// Reset input when repository changes
useEffect(() => {
setInputValue("");
}, [repository]);
// Initialize input value when selectedBranch changes (but not when user is typing)
useEffect(() => {
if (selectedBranch && !isOpen && inputValue !== selectedBranch.name) {
setInputValue(selectedBranch.name);
} else if (!selectedBranch && !isOpen && inputValue) {
setInputValue("");
}
}, [selectedBranch, isOpen, inputValue]);
const isLoadingState = isLoading || isSearchLoading || isFetchingNextPage;
return (
<div className={cn("relative", className)}>
<div className="relative">
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...getInputProps({
disabled: disabled || !repository,
placeholder,
className: cn(
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
"pr-10", // Space for toggle button
),
})}
data-testid="git-branch-dropdown-input"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
{selectedBranch && (
<ClearButton disabled={disabled} onClear={handleClear} />
)}
<ToggleButton
isOpen={isOpen}
disabled={disabled || !repository}
getToggleButtonProps={getToggleButtonProps}
/>
</div>
{isLoadingState && <LoadingSpinner hasSelection={!!selectedBranch} />}
</div>
<BranchDropdownMenu
isOpen={isOpen}
filteredBranches={filteredBranches}
inputValue={inputValue}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
onScroll={handleMenuScroll}
menuRef={menuRef}
/>
<ErrorMessage isError={!!error} />
</div>
);
}

View File

@@ -1,3 +0,0 @@
export { GitBranchDropdown } from "./git-branch-dropdown";
export { BranchDropdownMenu } from "./branch-dropdown-menu";
export type { GitBranchDropdownProps } from "./git-branch-dropdown";

View File

@@ -1,193 +0,0 @@
import React, { useState, useMemo, useEffect } from "react";
import { useCombobox } from "downshift";
import { Provider } from "#/types/settings";
import { cn } from "#/utils/utils";
import { DropdownItem } from "../shared/dropdown-item";
import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
import { ToggleButton } from "../shared/toggle-button";
import { LoadingSpinner } from "../shared/loading-spinner";
import { ErrorMessage } from "../shared/error-message";
import { EmptyState } from "../shared/empty-state";
export interface GitProviderDropdownProps {
providers: Provider[];
value?: Provider | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
isLoading?: boolean;
onChange?: (provider: Provider | null) => void;
}
export function GitProviderDropdown({
providers,
value,
placeholder = "Select Provider",
className,
errorMessage,
disabled = false,
isLoading = false,
onChange,
}: GitProviderDropdownProps) {
const [inputValue, setInputValue] = useState("");
const [localSelectedItem, setLocalSelectedItem] = useState<Provider | null>(
value || null,
);
// Format provider names for display
const formatProviderName = (provider: Provider): string => {
switch (provider) {
case "github":
return "GitHub";
case "gitlab":
return "GitLab";
case "bitbucket":
return "Bitbucket";
case "enterprise_sso":
return "Enterprise SSO";
default:
// Fallback for any future provider types
return (
(provider as string).charAt(0).toUpperCase() +
(provider as string).slice(1)
);
}
};
// Filter providers based on input value
const filteredProviders = useMemo(() => {
// If we have a selected provider and the input matches it exactly, show all providers
if (
localSelectedItem &&
inputValue === formatProviderName(localSelectedItem)
) {
return providers;
}
// If no input value, show all providers
if (!inputValue || !inputValue.trim()) {
return providers;
}
// Filter providers based on input
return providers.filter((provider) =>
formatProviderName(provider)
.toLowerCase()
.includes(inputValue.toLowerCase()),
);
}, [providers, inputValue, localSelectedItem]);
const {
isOpen,
getToggleButtonProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
items: filteredProviders,
itemToString: (item) => (item ? formatProviderName(item) : ""),
selectedItem: localSelectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
setLocalSelectedItem(newSelectedItem || null);
onChange?.(newSelectedItem || null);
},
onInputValueChange: ({ inputValue: newInputValue }) => {
setInputValue(newInputValue || "");
},
inputValue,
});
// Sync with external value prop
useEffect(() => {
if (value !== localSelectedItem) {
setLocalSelectedItem(value || null);
}
}, [value, localSelectedItem]);
// Update input value when selection changes (but not when user is typing)
useEffect(() => {
if (selectedItem && !isOpen) {
setInputValue(formatProviderName(selectedItem));
} else if (!selectedItem) {
setInputValue("");
}
}, [selectedItem, isOpen]);
const renderItem = (
item: Provider,
index: number,
currentHighlightedIndex: number,
currentSelectedItem: Provider | null,
currentGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => (
<DropdownItem
key={item}
item={item}
index={index}
isHighlighted={index === currentHighlightedIndex}
isSelected={item === currentSelectedItem}
getItemProps={currentGetItemProps}
getDisplayText={formatProviderName}
getItemKey={(provider) => provider}
/>
);
const renderEmptyState = (currentInputValue: string) => (
<EmptyState
inputValue={currentInputValue}
searchMessage="No providers found"
emptyMessage="No providers available"
testId="git-provider-dropdown-empty"
/>
);
return (
<div className={cn("relative", className)}>
<div className="relative">
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...getInputProps({
disabled,
placeholder,
readOnly: true, // Make it non-searchable like the original
className: cn(
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
"pr-10 cursor-pointer", // Space for toggle button and pointer cursor
),
})}
data-testid="git-provider-dropdown"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
<ToggleButton
isOpen={isOpen}
disabled={disabled}
getToggleButtonProps={getToggleButtonProps}
/>
</div>
{isLoading && <LoadingSpinner hasSelection={!!selectedItem} />}
</div>
<GenericDropdownMenu
isOpen={isOpen}
filteredItems={filteredProviders}
inputValue={inputValue}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
/>
<ErrorMessage isError={!!errorMessage} message={errorMessage} />
</div>
);
}

View File

@@ -1,2 +0,0 @@
export { GitProviderDropdown } from "./git-provider-dropdown";
export type { GitProviderDropdownProps } from "./git-provider-dropdown";

View File

@@ -1,79 +0,0 @@
import React from "react";
import {
UseComboboxGetMenuPropsOptions,
UseComboboxGetItemPropsOptions,
} from "downshift";
import { GitRepository } from "#/types/git";
import { DropdownItem } from "../shared/dropdown-item";
import { GenericDropdownMenu, EmptyState } from "../shared";
interface DropdownMenuProps {
isOpen: boolean;
filteredRepositories: GitRepository[];
inputValue: string;
highlightedIndex: number;
selectedItem: GitRepository | null;
getMenuProps: <Options>(
options?: UseComboboxGetMenuPropsOptions & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
menuRef: React.RefObject<HTMLUListElement | null>;
}
export function DropdownMenu({
isOpen,
filteredRepositories,
inputValue,
highlightedIndex,
selectedItem,
getMenuProps,
getItemProps,
onScroll,
menuRef,
}: DropdownMenuProps) {
const renderItem = (
repository: GitRepository,
index: number,
currentHighlightedIndex: number,
currentSelectedItem: GitRepository | null,
currentGetItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => (
<DropdownItem
key={repository.id}
item={repository}
index={index}
isHighlighted={currentHighlightedIndex === index}
isSelected={currentSelectedItem?.id === repository.id}
getItemProps={currentGetItemProps}
getDisplayText={(repo) => repo.full_name}
getItemKey={(repo) => repo.id.toString()}
/>
);
const renderEmptyState = (currentInputValue: string) => (
<EmptyState inputValue={currentInputValue} />
);
return (
<div data-testid="git-repo-dropdown-menu">
<GenericDropdownMenu
isOpen={isOpen}
filteredItems={filteredRepositories}
inputValue={inputValue}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
onScroll={onScroll}
menuRef={menuRef}
renderItem={renderItem}
renderEmptyState={renderEmptyState}
/>
</div>
);
}

View File

@@ -1,243 +0,0 @@
import React, {
useState,
useMemo,
useCallback,
useRef,
useEffect,
} from "react";
import { useCombobox } from "downshift";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
import { useDebounce } from "#/hooks/use-debounce";
import { cn } from "#/utils/utils";
import { LoadingSpinner } from "../shared/loading-spinner";
import { ClearButton } from "../shared/clear-button";
import { ToggleButton } from "../shared/toggle-button";
import { ErrorMessage } from "../shared/error-message";
import { useUrlSearch } from "./use-url-search";
import { useRepositoryData } from "./use-repository-data";
import { DropdownMenu } from "./dropdown-menu";
export interface GitRepoDropdownProps {
provider: Provider;
value?: string | null;
placeholder?: string;
className?: string;
disabled?: boolean;
onChange?: (repository?: GitRepository) => void;
}
export function GitRepoDropdown({
provider,
value,
placeholder = "Search repositories...",
className,
disabled = false,
onChange,
}: GitRepoDropdownProps) {
const [inputValue, setInputValue] = useState("");
const [localSelectedItem, setLocalSelectedItem] =
useState<GitRepository | null>(null);
const debouncedInputValue = useDebounce(inputValue, 300);
const menuRef = useRef<HTMLUListElement>(null);
// Process search input to handle URLs
const processedSearchInput = useMemo(() => {
if (debouncedInputValue.startsWith("https://")) {
const match = debouncedInputValue.match(
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
);
return match ? match[1] : debouncedInputValue;
}
return debouncedInputValue;
}, [debouncedInputValue]);
// URL search functionality
const { urlSearchResults, isUrlSearchLoading } = useUrlSearch(
inputValue,
provider,
);
// Repository data management
const {
repositories,
selectedRepository,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
isSearchLoading,
} = useRepositoryData(
provider,
disabled,
processedSearchInput,
urlSearchResults,
inputValue,
value,
);
// Filter repositories based on input value
const filteredRepositories = useMemo(() => {
// If we have URL search results, show them directly (no filtering needed)
if (urlSearchResults.length > 0) {
return repositories;
}
// If we have a selected repository and the input matches it exactly, show all repositories
if (selectedRepository && inputValue === selectedRepository.full_name) {
return repositories;
}
// If no input value, show all repositories
if (!inputValue || !inputValue.trim()) {
return repositories;
}
// For URL inputs, use the processed search input for filtering
const filterText = inputValue.startsWith("https://")
? processedSearchInput
: inputValue;
return repositories.filter((repo) =>
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
);
}, [
repositories,
inputValue,
selectedRepository,
urlSearchResults,
processedSearchInput,
]);
// Handle selection
const handleSelectionChange = useCallback(
(selectedItem: GitRepository | null) => {
setLocalSelectedItem(selectedItem);
onChange?.(selectedItem || undefined);
// Update input value to show selected item
if (selectedItem) {
setInputValue(selectedItem.full_name);
}
},
[onChange],
);
// Handle clear selection
const handleClear = useCallback(() => {
setLocalSelectedItem(null);
handleSelectionChange(null);
setInputValue("");
}, [handleSelectionChange]);
// Handle input value change
const handleInputValueChange = useCallback(
({ inputValue: newInputValue }: { inputValue?: string }) => {
setInputValue(newInputValue || "");
},
[],
);
// Handle scroll to bottom for pagination
const handleMenuScroll = useCallback(
(event: React.UIEvent<HTMLUListElement>) => {
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[hasNextPage, isFetchingNextPage, fetchNextPage],
);
const {
isOpen,
getToggleButtonProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
items: filteredRepositories,
itemToString: (item) => item?.full_name || "",
selectedItem: localSelectedItem,
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
handleSelectionChange(newSelectedItem);
},
onInputValueChange: handleInputValueChange,
inputValue,
});
// Sync localSelectedItem with external value prop
useEffect(() => {
if (selectedRepository) {
setLocalSelectedItem(selectedRepository);
} else if (value === null) {
setLocalSelectedItem(null);
}
}, [selectedRepository, value]);
// Initialize input value when selectedRepository changes (but not when user is typing)
useEffect(() => {
if (selectedRepository && !isOpen) {
setInputValue(selectedRepository.full_name);
}
}, [selectedRepository, isOpen]);
const isLoadingState =
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
return (
<div className={cn("relative", className)}>
<div className="relative">
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...getInputProps({
disabled,
placeholder,
className: cn(
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
"pr-10", // Space for toggle button
),
})}
data-testid="git-repo-dropdown"
/>
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
{selectedRepository && (
<ClearButton disabled={disabled} onClear={handleClear} />
)}
<ToggleButton
isOpen={isOpen}
disabled={disabled}
getToggleButtonProps={getToggleButtonProps}
/>
</div>
{isLoadingState && (
<LoadingSpinner hasSelection={!!selectedRepository} />
)}
</div>
<DropdownMenu
isOpen={isOpen}
filteredRepositories={filteredRepositories}
inputValue={inputValue}
highlightedIndex={highlightedIndex}
selectedItem={selectedItem}
getMenuProps={getMenuProps}
getItemProps={getItemProps}
onScroll={handleMenuScroll}
menuRef={menuRef}
/>
<ErrorMessage isError={isError} />
</div>
);
}

View File

@@ -1,10 +0,0 @@
// Main component
export { GitRepoDropdown } from "./git-repo-dropdown";
export type { GitRepoDropdownProps } from "./git-repo-dropdown";
// Repository-specific UI Components
export { DropdownMenu } from "./dropdown-menu";
// Repository-specific Custom Hooks
export { useUrlSearch } from "./use-url-search";
export { useRepositoryData } from "./use-repository-data";

View File

@@ -1,118 +0,0 @@
import { useMemo, useEffect } from "react";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
export function useRepositoryData(
provider: Provider,
disabled: boolean,
processedSearchInput: string,
urlSearchResults: GitRepository[],
inputValue: string,
value?: string | null,
) {
// Fetch user repositories with pagination
const {
data: repoData,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
} = useGitRepositories({
provider,
enabled: !disabled,
});
// Search repositories when user types
const { data: searchData, isLoading: isSearchLoading } =
useSearchRepositories(processedSearchInput, provider);
// Combine all repositories from paginated data
const allRepositories = useMemo(
() => repoData?.pages?.flatMap((page) => page.data) || [],
[repoData],
);
// Find selected repository from all possible sources
const selectedRepository = useMemo(() => {
if (!value) return null;
// Search in all possible repository sources
const allPossibleRepos = [
...allRepositories,
...urlSearchResults,
...(searchData || []),
];
return allPossibleRepos.find((repo) => repo.id === value) || null;
}, [allRepositories, urlSearchResults, searchData, value]);
// Get repositories to display (URL search, regular search, or all repos)
const repositories = useMemo(() => {
// Prioritize URL search results when available
if (urlSearchResults.length > 0) {
return urlSearchResults;
}
// Don't use search results if input exactly matches selected repository
const shouldUseSearch =
processedSearchInput &&
searchData &&
!(selectedRepository && inputValue === selectedRepository.full_name);
if (shouldUseSearch) {
return searchData;
}
return allRepositories;
}, [
urlSearchResults,
processedSearchInput,
searchData,
allRepositories,
selectedRepository,
inputValue,
]);
// Auto-load more repositories when there aren't enough items to create a scrollable dropdown
// This is particularly important for SaaS mode with installations that might have very few repos
useEffect(() => {
const shouldAutoLoad =
!disabled &&
!isLoading &&
!isFetchingNextPage &&
!isSearchLoading &&
hasNextPage &&
!processedSearchInput && // Not during search (use all repos, not search results)
urlSearchResults.length === 0 &&
repositories.length > 0 && // Have some repositories loaded
repositories.length < 10; // But not enough to create a scrollable dropdown
if (shouldAutoLoad) {
fetchNextPage();
}
}, [
disabled,
isLoading,
isFetchingNextPage,
isSearchLoading,
hasNextPage,
processedSearchInput,
urlSearchResults.length,
repositories.length,
fetchNextPage,
]);
return {
repositories,
allRepositories,
selectedRepository,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
isSearchLoading,
};
}

View File

@@ -1,41 +0,0 @@
import { useState, useEffect } from "react";
import { Provider } from "#/types/settings";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
export function useUrlSearch(inputValue: string, provider: Provider) {
const [urlSearchResults, setUrlSearchResults] = useState<GitRepository[]>([]);
const [isUrlSearchLoading, setIsUrlSearchLoading] = useState(false);
useEffect(() => {
const handleUrlSearch = async () => {
if (inputValue.startsWith("https://")) {
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
if (match) {
const repoName = match[1];
setIsUrlSearchLoading(true);
try {
const repositories = await OpenHands.searchGitRepositories(
repoName,
3,
provider,
);
setUrlSearchResults(repositories);
} catch (error) {
setUrlSearchResults([]);
} finally {
setIsUrlSearchLoading(false);
}
}
} else {
setUrlSearchResults([]);
}
};
handleUrlSearch();
}, [inputValue, provider]);
return { urlSearchResults, isUrlSearchLoading };
}

View File

@@ -2,15 +2,15 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { GitProviderDropdown } from "./git-provider-dropdown";
import { GitBranchDropdown } from "./git-branch-dropdown";
import { GitRepoDropdown } from "./git-repo-dropdown";
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
interface RepositorySelectionFormProps {
onRepoSelection: (repo: GitRepository | null) => void;
@@ -28,6 +28,8 @@ export function RepositorySelectionForm({
const [selectedProvider, setSelectedProvider] =
React.useState<Provider | null>(null);
const { providers } = useUserProviders();
const { data: branches, isLoading: isLoadingBranches } =
useRepositoryBranches(selectedRepository?.full_name || null);
const {
mutate: createConversation,
isPending,
@@ -48,7 +50,8 @@ export function RepositorySelectionForm({
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Branch selection is now handled by GitBranchDropdown component
// Check if repository has no branches (empty array after loading completes)
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
const handleProviderSelection = (provider: Provider | null) => {
setSelectedProvider(provider);
@@ -57,9 +60,14 @@ export function RepositorySelectionForm({
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = React.useCallback((branch: Branch | null) => {
setSelectedBranch(branch);
}, []);
const handleBranchSelection = (branchName: string | null) => {
const selectedBranchObj = branches?.find(
(branch) => branch.name === branchName,
);
if (selectedBranchObj) {
setSelectedBranch(selectedBranchObj);
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
@@ -79,6 +87,19 @@ export function RepositorySelectionForm({
);
};
// Effect to auto-select main/master branch when branches are loaded
React.useEffect(() => {
if (branches?.length) {
// Look for main or master branch
const defaultBranch = branches.find(
(branch) => branch.name === "main" || branch.name === "master",
);
// If found, select it, otherwise select the first branch
setSelectedBranch(defaultBranch || branches[0]);
}
}, [branches]);
// Render the repository selector using our new component
const renderRepositorySelector = () => {
const handleRepoSelection = (repository?: GitRepository) => {
@@ -86,14 +107,13 @@ export function RepositorySelectionForm({
onRepoSelection(repository);
setSelectedRepository(repository);
} else {
onRepoSelection(null); // Notify parent component that repo was cleared
setSelectedRepository(null);
setSelectedBranch(null);
}
};
return (
<GitRepoDropdown
<GitRepositoryDropdown
provider={selectedProvider || providers[0]}
value={selectedRepository?.id || null}
placeholder="Search repositories..."
@@ -105,21 +125,16 @@ export function RepositorySelectionForm({
};
// Render the branch selector
const renderBranchSelector = () => {
const defaultBranch = selectedRepository?.main_branch || null;
return (
<GitBranchDropdown
repository={selectedRepository?.full_name || null}
provider={selectedProvider || providers[0]}
selectedBranch={selectedBranch}
onBranchSelect={handleBranchSelection}
defaultBranch={defaultBranch}
placeholder="Select branch..."
className="max-w-[500px]"
disabled={!selectedRepository}
/>
);
};
const renderBranchSelector = () => (
<GitBranchDropdown
repositoryName={selectedRepository?.full_name}
value={selectedBranch?.name || null}
placeholder="Select branch..."
className="max-w-[500px]"
disabled={!selectedRepository}
onChange={handleBranchSelection}
/>
);
return (
<div className="flex flex-col gap-4">
@@ -133,7 +148,8 @@ export function RepositorySelectionForm({
type="button"
isDisabled={
!selectedRepository ||
!selectedBranch ||
(!selectedBranch && !hasNoBranches) ||
isLoadingBranches ||
isCreatingConversation ||
(providers.length > 1 && !selectedProvider)
}
@@ -143,7 +159,7 @@ export function RepositorySelectionForm({
repository: {
name: selectedRepository?.full_name || "",
gitProvider: selectedRepository?.git_provider || "github",
branch: selectedBranch?.name || "main",
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
},
},
{

View File

@@ -1,45 +0,0 @@
import React from "react";
import { cn } from "#/utils/utils";
interface ClearButtonProps {
disabled: boolean;
onClear: () => void;
testId?: string;
}
export function ClearButton({
disabled,
onClear,
testId = "dropdown-clear",
}: ClearButtonProps) {
return (
<button
onClick={(e) => {
e.stopPropagation();
onClear();
}}
disabled={disabled}
className={cn(
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
type="button"
aria-label="Clear selection"
data-testid={testId}
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
);
}

View File

@@ -1,44 +0,0 @@
import React from "react";
import { cn } from "#/utils/utils";
interface DropdownItemProps<T> {
item: T;
index: number;
isHighlighted: boolean;
isSelected: boolean;
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getDisplayText: (item: T) => string;
getItemKey: (item: T) => string;
}
export function DropdownItem<T>({
item,
index,
isHighlighted,
isSelected,
getItemProps,
getDisplayText,
getItemKey,
}: DropdownItemProps<T>) {
const itemProps = getItemProps({
index,
item,
className: cn(
"px-3 py-2 cursor-pointer text-sm rounded-lg mx-0.5 my-0.5",
"text-[#ECEDEE] focus:outline-none",
{
"bg-[#24272E]": isHighlighted && !isSelected,
"bg-[#C9B974] text-black": isSelected,
"hover:bg-[#24272E]": !isSelected,
"hover:bg-[#C9B974] hover:text-black": isSelected,
},
),
});
return (
// eslint-disable-next-line react/jsx-props-no-spreading
<li key={getItemKey(item)} {...itemProps}>
<span className="font-medium">{getDisplayText(item)}</span>
</li>
);
}

View File

@@ -1,24 +0,0 @@
import React from "react";
interface EmptyStateProps {
inputValue: string;
searchMessage?: string;
emptyMessage?: string;
testId?: string;
}
export function EmptyState({
inputValue,
searchMessage = "No items found",
emptyMessage = "No items available",
testId = "dropdown-empty",
}: EmptyStateProps) {
return (
<li
className="px-3 py-2 text-[#B7BDC2] text-sm rounded-lg mx-0.5 my-0.5"
data-testid={testId}
>
{inputValue ? searchMessage : emptyMessage}
</li>
);
}

View File

@@ -1,21 +0,0 @@
import React from "react";
interface ErrorMessageProps {
isError: boolean;
message?: string;
testId?: string;
}
export function ErrorMessage({
isError,
message = "Failed to load data",
testId = "dropdown-error",
}: ErrorMessageProps) {
if (!isError) return null;
return (
<div className="text-red-500 text-sm mt-1" data-testid={testId}>
{message}
</div>
);
}

View File

@@ -1,74 +0,0 @@
import React from "react";
import {
UseComboboxGetMenuPropsOptions,
UseComboboxGetItemPropsOptions,
} from "downshift";
import { cn } from "#/utils/utils";
export interface GenericDropdownMenuProps<T> {
isOpen: boolean;
filteredItems: T[];
inputValue: string;
highlightedIndex: number;
selectedItem: T | null;
getMenuProps: <Options>(
options?: UseComboboxGetMenuPropsOptions & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<T> & Options,
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;
menuRef?: React.RefObject<HTMLUListElement | null>;
renderItem: (
item: T,
index: number,
highlightedIndex: number,
selectedItem: T | null,
getItemProps: <Options>(
options: UseComboboxGetItemPropsOptions<T> & Options,
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
) => React.ReactNode;
renderEmptyState: (inputValue: string) => React.ReactNode;
}
export function GenericDropdownMenu<T>({
isOpen,
filteredItems,
inputValue,
highlightedIndex,
selectedItem,
getMenuProps,
getItemProps,
onScroll,
menuRef,
renderItem,
renderEmptyState,
}: GenericDropdownMenuProps<T>) {
if (!isOpen) return null;
return (
<ul
// eslint-disable-next-line react/jsx-props-no-spreading
{...getMenuProps({
ref: menuRef,
className: cn(
"absolute z-10 w-full bg-[#454545] border border-[#717888] rounded-xl shadow-lg max-h-60 overflow-auto",
"focus:outline-none p-1 gap-2 flex flex-col",
),
onScroll,
})}
>
{filteredItems.length === 0
? renderEmptyState(inputValue)
: filteredItems.map((item, index) =>
renderItem(
item,
index,
highlightedIndex,
selectedItem,
getItemProps,
),
)}
</ul>
);
}

View File

@@ -1,7 +0,0 @@
export { GenericDropdownMenu } from "./generic-dropdown-menu";
export { EmptyState } from "./empty-state";
export { ErrorMessage } from "./error-message";
export { LoadingSpinner } from "./loading-spinner";
export { ClearButton } from "./clear-button";
export { ToggleButton } from "./toggle-button";
export type { GenericDropdownMenuProps } from "./generic-dropdown-menu";

View File

@@ -1,26 +0,0 @@
import React from "react";
import { cn } from "#/utils/utils";
interface LoadingSpinnerProps {
hasSelection: boolean;
testId?: string;
}
export function LoadingSpinner({
hasSelection,
testId = "dropdown-loading",
}: LoadingSpinnerProps) {
return (
<div
className={cn(
"absolute top-1/2 transform -translate-y-1/2",
hasSelection ? "right-16" : "right-12",
)}
>
<div
className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"
data-testid={testId}
/>
</div>
);
}

View File

@@ -1,45 +0,0 @@
import React from "react";
import { cn } from "#/utils/utils";
interface ToggleButtonProps {
isOpen: boolean;
disabled: boolean;
getToggleButtonProps: (
props?: Record<string, unknown>,
) => Record<string, unknown>;
}
export function ToggleButton({
isOpen,
disabled,
getToggleButtonProps,
}: ToggleButtonProps) {
return (
<button
// eslint-disable-next-line react/jsx-props-no-spreading
{...getToggleButtonProps({
disabled,
className: cn(
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
"disabled:cursor-not-allowed disabled:opacity-60",
),
})}
type="button"
aria-label="Toggle menu"
>
<svg
className={cn("w-4 h-4 transition-transform", isOpen && "rotate-180")}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
);
}

View File

@@ -17,7 +17,7 @@ export function MicroagentManagementAccordionTitle({
<TooltipButton
tooltip={repository.full_name}
ariaLabel={repository.full_name}
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[200px] translate-y-[-1px]"
testId="repository-name-tooltip"
placement="bottom"
>

View File

@@ -32,7 +32,6 @@ import {
} from "#/utils/custom-toast-handlers";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
@@ -66,10 +65,16 @@ const getConversationInstructions = (
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the instructions about what the microagent should do: ${formData.query}. ${
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- This is the instructions about what the microagent should do: ${formData.query}
${
formData.triggers && formData.triggers.length > 0
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
? `
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
`
: "- Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
@@ -86,10 +91,16 @@ const getUpdateConversationInstructions = (
) => `Update the microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the updated instructions about what the microagent should do: ${formData.query}. ${
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered).
- This is the updated instructions about what the microagent should do: ${formData.query}
${
formData.triggers && formData.triggers.length > 0
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
: "Please be noted that the microagent doesn't have any triggers."
? `
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
`
: "- Please be noted that the microagent doesn't have any triggers."
}
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
@@ -108,8 +119,6 @@ export function MicroagentManagementContent() {
learnThisRepoModalVisible,
} = useSelector((state: RootState) => state.microagentManagement);
const { providers } = useUserProviders();
const { t } = useTranslation();
const dispatch = useDispatch();
@@ -173,7 +182,11 @@ export function MicroagentManagementContent() {
// Check if agent has finished and we have a PR
if (isOpenHandsEvent(socketEvent) && isFinishAction(socketEvent)) {
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (!prUrl) {
if (prUrl) {
displaySuccessToast(
t(I18nKey.MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW),
);
} else {
// Agent finished but no PR found
displaySuccessToast(t(I18nKey.MICROAGENT_MANAGEMENT$PR_NOT_CREATED));
}
@@ -240,6 +253,7 @@ export function MicroagentManagementContent() {
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
@@ -276,12 +290,6 @@ export function MicroagentManagementContent() {
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
// Launch a new conversation to help the user understand the repo
createConversationAndSubscribe({
query: formData.query,
@@ -291,7 +299,6 @@ export function MicroagentManagementContent() {
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideLearnThisRepoModal();
},
@@ -322,18 +329,11 @@ export function MicroagentManagementContent() {
</>
);
const providersAreSet = providers.length > 0;
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
{providersAreSet && (
<MicroagentManagementSidebar
isSmallerScreen
providers={providers}
/>
)}
<MicroagentManagementSidebar isSmallerScreen />
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
@@ -345,7 +345,7 @@ export function MicroagentManagementContent() {
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
{providersAreSet && <MicroagentManagementSidebar providers={providers} />}
<MicroagentManagementSidebar />
<div className="flex-1">
<MicroagentManagementMain />
</div>

View File

@@ -8,7 +8,7 @@ import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
import { cn } from "#/utils/utils";
import { LearnThisRepoFormData } from "#/types/microagent-management";
import { Branch } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
@@ -76,25 +76,23 @@ export function MicroagentManagementLearnThisRepoModal({
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
if (!query.trim()) {
return;
}
onConfirm({
query: finalQuery,
query: query.trim(),
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
if (!query.trim()) {
return;
}
onConfirm({
query: finalQuery,
query: query.trim(),
selectedBranch: selectedBranch?.name || "",
});
};
@@ -246,6 +244,7 @@ export function MicroagentManagementLearnThisRepoModal({
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||

View File

@@ -59,10 +59,8 @@ export function MicroagentManagementMicroagentCard({
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING") {
return runtimeStatus === "STATUS$READY"
? t(I18nKey.MICROAGENT$STATUS_OPENING_PR)
: t(I18nKey.COMMON$STARTING);
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);

View File

@@ -1,16 +1,13 @@
import { useTranslation } from "react-i18next";
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { Spinner } from "@heroui/react";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { GitRepository } from "#/types/git";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
interface MicroagentManagementRepoMicroagentsProps {
repository: GitRepository;
@@ -25,8 +22,6 @@ export function MicroagentManagementRepoMicroagents({
const dispatch = useDispatch();
const { t } = useTranslation();
const { full_name: repositoryName } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
@@ -42,9 +37,9 @@ export function MicroagentManagementRepoMicroagents({
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useMicroagentManagementConversations(
} = useSearchConversations(
repositoryName,
undefined,
"microagent_management",
1000,
true,
);
@@ -108,47 +103,34 @@ export function MicroagentManagementRepoMicroagents({
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
const hasMicroagents = numberOfMicroagents > 0;
const hasConversations = numberOfConversations > 0;
return (
<div>
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo repository={repository} />
)}
{/* Render microagents */}
{hasMicroagents && (
<div className="flex flex-col">
<span className="text-md text-white font-medium leading-5 mb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS)}
</span>
{microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
</div>
)}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
{/* Render conversations */}
{hasConversations && (
<div className={cn("flex flex-col", hasMicroagents && "mt-4")}>
<span className="text-md text-white font-medium leading-5 mb-4">
{t(I18nKey.COMMON$IN_PROGRESS)}
</span>
{conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
/>
</div>
))}
</div>
)}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
/>
</div>
))}
</div>
);
}

View File

@@ -1,39 +1,42 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem, Spinner } from "@heroui/react";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
isSearchLoading?: boolean;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
isSearchLoading = false,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Show spinner when search is in progress, regardless of repository count
if (isSearchLoading) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-8">
<Spinner size="sm" />
<span className="text-sm text-white">
{t("HOME$SEARCHING_REPOSITORIES")}
</span>
</div>
);
}
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
@@ -70,6 +73,25 @@ export function MicroagentManagementRepositories({
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
@@ -82,7 +104,7 @@ export function MicroagentManagementRepositories({
}}
selectionMode="multiple"
>
{repositories.map((repository) => (
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}

View File

@@ -5,13 +5,7 @@ import { MicroagentManagementRepositories } from "./microagent-management-reposi
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
interface MicroagentManagementSidebarTabsProps {
isSearchLoading?: boolean;
}
export function MicroagentManagementSidebarTabs({
isSearchLoading = false,
}: MicroagentManagementSidebarTabsProps) {
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
@@ -35,21 +29,18 @@ export function MicroagentManagementSidebarTabs({
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
isSearchLoading={isSearchLoading}
/>
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
isSearchLoading={isSearchLoading}
/>
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
isSearchLoading={isSearchLoading}
/>
</Tab>
</Tabs>

View File

@@ -1,145 +1,59 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useUserProviders } from "#/hooks/use-user-providers";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { Provider } from "#/types/settings";
import { cn } from "#/utils/utils";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { I18nKey } from "#/i18n/declaration";
import { useDebounce } from "#/hooks/use-debounce";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
providers: Provider[];
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
providers,
}: MicroagentManagementSidebarProps) {
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(
providers.length > 0 ? providers[0] : null,
);
const [searchQuery, setSearchQuery] = useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const dispatch = useDispatch();
const { t } = useTranslation();
// Use Git repositories hook with pagination for infinite scrolling
const {
data: repositories,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
} = useGitRepositories({
provider: selectedProvider,
pageSize: 30, // Load 30 repositories per page
enabled: !!selectedProvider,
});
// Server-side search functionality
const { data: searchResults, isLoading: isSearchLoading } =
useSearchRepositories(debouncedSearchQuery, selectedProvider, 500); // Increase page size to 500 to to retrieve all search results. This should be optimized in the future.
// Auto-select provider if there's only one
useEffect(() => {
if (providers.length > 0 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
const handleProviderChange = (provider: Provider | null) => {
setSelectedProvider(provider);
setSearchQuery("");
};
// Filter repositories based on search query and available data
const filteredRepositories = useMemo(() => {
// If we have search results, use them directly (no filtering needed)
if (debouncedSearchQuery && searchResults && searchResults.length > 0) {
return searchResults;
}
// If no search query or no search results, use paginated repositories
if (!repositories?.pages) return [];
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
// If no search query, return all repositories
if (!debouncedSearchQuery.trim()) {
return allRepositories;
}
// Fallback to client-side filtering if search didn't return results
const sanitizedQuery = sanitizeQuery(debouncedSearchQuery);
return allRepositories.filter((repository: GitRepository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, debouncedSearchQuery, searchResults]);
const { providers } = useUserProviders();
const selectedProvider = providers.length > 0 ? providers[0] : null;
const { data: repositories, isLoading } =
useUserRepositories(selectedProvider);
useEffect(() => {
if (!filteredRepositories?.length) {
dispatch(setPersonalRepositories([]));
dispatch(setOrganizationRepositories([]));
dispatch(setRepositories([]));
return;
if (repositories?.pages) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
allRepositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
filteredRepositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix =
selectedProvider === "gitlab"
? repo.full_name.endsWith("/openhands-config")
: repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}, [filteredRepositories, selectedProvider, dispatch]);
// Handle scroll to bottom for pagination
const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
// Only enable pagination when not searching
if (debouncedSearchQuery && searchResults) {
return;
}
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
}, [repositories, dispatch]);
return (
<div
@@ -147,50 +61,8 @@ export function MicroagentManagementSidebar({
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
onScroll={handleScroll}
>
<MicroagentManagementSidebarHeader />
{/* Provider Selection */}
{providers.length > 1 && (
<div className="mt-6">
<GitProviderDropdown
providers={providers}
value={selectedProvider}
placeholder="Select Provider"
onChange={handleProviderChange}
className="w-full"
/>
</div>
)}
{/* Search Input */}
<div className="flex flex-col gap-2 w-full mt-6">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<div className="relative">
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
"pr-10", // Space for spinner
)}
/>
{isSearchLoading && (
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
<Spinner size="sm" />
</div>
)}
</div>
</div>
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<Spinner size="sm" />
@@ -199,19 +71,7 @@ export function MicroagentManagementSidebar({
</span>
</div>
) : (
<>
<MicroagentManagementSidebarTabs isSearchLoading={isSearchLoading} />
{/* Show loading indicator for pagination (only when not searching) */}
{isFetchingNextPage && !debouncedSearchQuery && (
<div className="flex justify-center pt-2">
<Spinner size="sm" />
<span className="text-sm text-white ml-2">
{t("HOME$LOADING_MORE_REPOSITORIES")}
</span>
</div>
)}
</>
<MicroagentManagementSidebarTabs />
)}
</div>
);

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useRef, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -11,8 +11,14 @@ import XIcon from "#/icons/x.svg?react";
import { cn, extractRepositoryInfo } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { GitRepository } from "#/types/git";
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,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementUpsertMicroagentModalProps {
onConfirm: (formData: MicroagentFormData) => void;
@@ -31,6 +37,7 @@ export function MicroagentManagementUpsertMicroagentModal({
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
@@ -42,6 +49,9 @@ export function MicroagentManagementUpsertMicroagentModal({
const { microagent } = selectedMicroagentItem ?? {};
// 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,
@@ -60,6 +70,38 @@ export function MicroagentManagementUpsertMicroagentModal({
}
}, [isUpdate, microagentContentData]);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = useMemo(() => {
if (isUpdate) {
return t(I18nKey.MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT);
@@ -92,6 +134,7 @@ export function MicroagentManagementUpsertMicroagentModal({
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
microagentPath: microagent?.path || "",
});
};
@@ -104,10 +147,67 @@ export function MicroagentManagementUpsertMicroagentModal({
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
microagentPath: microagent?.path || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
<ModalBackdrop onClose={onCancel}>
<ModalBody className="items-start rounded-[12px] p-6 min-w-[611px]">
@@ -136,6 +236,7 @@ export function MicroagentManagementUpsertMicroagentModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -200,10 +301,15 @@ export function MicroagentManagementUpsertMicroagentModal({
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() || isLoading || (isUpdate && isLoadingContent) // Disable while loading content for updates
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError ||
(isUpdate && isLoadingContent) // Disable while loading content for updates
}
>
{isLoading || (isUpdate && isLoadingContent)
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>

View File

@@ -1,7 +1,8 @@
import { Tooltip } from "@heroui/react";
import { useTranslation } from "react-i18next";
import ConfirmIcon from "#/assets/confirm";
import RejectIcon from "#/assets/reject";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
interface ActionTooltipProps {
type: "confirm" | "reject";
@@ -11,35 +12,25 @@ interface ActionTooltipProps {
export function ActionTooltip({ type, onClick }: ActionTooltipProps) {
const { t } = useTranslation();
const isConfirm = type === "confirm";
const ariaLabel = isConfirm
? t(I18nKey.ACTION$CONFIRM)
: t(I18nKey.ACTION$REJECT);
const content = isConfirm
? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
: t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
const buttonLabel = isConfirm
? `${t(I18nKey.CHAT_INTERFACE$INPUT_CONTINUE_MESSAGE)} ⌘↩`
: `${t(I18nKey.BUTTON$CANCEL)} ⇧⌘⌫`;
const content =
type === "confirm"
? t(I18nKey.CHAT_INTERFACE$USER_CONFIRMED)
: t(I18nKey.CHAT_INTERFACE$USER_REJECTED);
return (
<Tooltip content={content} closeDelay={100}>
<button
data-testid={`action-${type}-button`}
type="button"
aria-label={ariaLabel}
className={cn(
"rounded px-2 h-6.5 text-sm font-medium leading-5 cursor-pointer hover:opacity-80",
aria-label={
type === "confirm"
? "bg-tertiary text-white"
: "bg-white text-[#0D0F11]",
)}
? t(I18nKey.ACTION$CONFIRM)
: t(I18nKey.ACTION$REJECT)
}
className="bg-tertiary rounded-full p-1 hover:bg-base-secondary"
onClick={onClick}
>
{buttonLabel}
{type === "confirm" ? <ConfirmIcon /> : <RejectIcon />}
</button>
</Tooltip>
);

View File

@@ -1,120 +1,31 @@
import { useDispatch, useSelector } from "react-redux";
import { useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { AgentState } from "#/types/agent-state";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { useWsClient } from "#/context/ws-client-provider";
import { ActionTooltip } from "../action-tooltip";
import { isOpenHandsAction } from "#/types/core/guards";
import { ActionSecurityRisk } from "#/state/security-analyzer-slice";
import { RiskAlert } from "#/components/shared/risk-alert";
import WarningIcon from "#/icons/u-warning.svg?react";
import { RootState } from "#/store";
import { addSubmittedEventId } from "#/state/event-message-slice";
export function ConfirmationButtons() {
const submittedEventIds = useSelector(
(state: RootState) => state.eventMessage.submittedEventIds,
);
const dispatch = useDispatch();
const { t } = useTranslation();
const { send } = useWsClient();
const { send, parsedEvents } = useWsClient();
// Find the most recent action awaiting confirmation
const awaitingAction = parsedEvents
.slice()
.reverse()
.find((ev) => {
if (!isOpenHandsAction(ev) || ev.source !== "agent") return false;
const args = ev.args as Record<string, unknown>;
return args?.confirmation_state === "awaiting_confirmation";
});
const handleStateChange = useCallback(
(state: AgentState) => {
if (!awaitingAction) {
return;
}
dispatch(addSubmittedEventId(awaitingAction.id));
send(generateAgentStateChangeEvent(state));
},
[send],
);
// Handle keyboard shortcuts
useEffect(() => {
if (!awaitingAction) {
return undefined;
}
const handleCancelShortcut = (event: KeyboardEvent) => {
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
event.preventDefault();
handleStateChange(AgentState.USER_REJECTED);
}
};
const handleContinueShortcut = (event: KeyboardEvent) => {
if (event.metaKey && event.key === "Enter") {
event.preventDefault();
handleStateChange(AgentState.USER_CONFIRMED);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
handleCancelShortcut(event);
// Continue: Cmd+Enter (⌘↩)
handleContinueShortcut(event);
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [awaitingAction, handleStateChange]);
if (!awaitingAction || submittedEventIds.includes(awaitingAction.id)) {
return null;
}
const { args } = awaitingAction as { args: Record<string, unknown> };
const risk = args?.security_risk;
const isHighRisk =
typeof risk === "string"
? risk.toLowerCase() === "high"
: Number(risk) === ActionSecurityRisk.HIGH;
const handleStateChange = (state: AgentState) => {
const event = generateAgentStateChangeEvent(state);
send(event);
};
return (
<div className="flex flex-col gap-2 pt-4">
{isHighRisk && (
<RiskAlert
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
icon={<WarningIcon width={16} height={16} color="#fff" />}
severity="high"
title={t(I18nKey.COMMON$HIGH_RISK)}
<div className="flex justify-between items-center pt-4">
<p>{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}</p>
<div className="flex items-center gap-3">
<ActionTooltip
type="confirm"
onClick={() => handleStateChange(AgentState.USER_CONFIRMED)}
/>
<ActionTooltip
type="reject"
onClick={() => handleStateChange(AgentState.USER_REJECTED)}
/>
)}
<div className="flex justify-between items-center">
<p className="text-sm font-normal text-white">
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
</p>
<div className="flex items-center gap-3">
<ActionTooltip
type="reject"
onClick={() => handleStateChange(AgentState.USER_REJECTED)}
/>
<ActionTooltip
type="confirm"
onClick={() => handleStateChange(AgentState.USER_CONFIRMED)}
/>
</div>
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import RobotIcon from "#/icons/robot.svg?react";
import UnionIcon from "#/icons/union.svg?react";
interface MicroagentManagementButtonProps {
disabled?: boolean;
@@ -22,7 +22,7 @@ export function MicroagentManagementButton({
testId="microagent-management-button"
disabled={disabled}
>
<RobotIcon width={28} height={28} />
<UnionIcon />
</TooltipButton>
);
}

View File

@@ -93,14 +93,14 @@ function SecurityInvariant() {
(risk: ActionSecurityRisk) => {
switch (risk) {
case ActionSecurityRisk.LOW:
return t(I18nKey.SECURITY$LOW_RISK);
return t(I18nKey.SECURITY_ANALYZER$LOW_RISK);
case ActionSecurityRisk.MEDIUM:
return t(I18nKey.SECURITY$MEDIUM_RISK);
return t(I18nKey.SECURITY_ANALYZER$MEDIUM_RISK);
case ActionSecurityRisk.HIGH:
return t(I18nKey.SECURITY$HIGH_RISK);
return t(I18nKey.SECURITY_ANALYZER$HIGH_RISK);
case ActionSecurityRisk.UNKNOWN:
default:
return t(I18nKey.SECURITY$UNKNOWN_RISK);
return t(I18nKey.SECURITY_ANALYZER$UNKNOWN_RISK);
}
},
[t],

View File

@@ -1,36 +0,0 @@
import { ReactNode } from "react";
import { cn } from "#/utils/utils";
interface RiskAlertProps {
className?: string;
content: ReactNode;
icon?: ReactNode;
severity: "high" | "medium" | "low";
title: string;
}
export function RiskAlert({
className,
content,
icon,
severity,
title,
}: RiskAlertProps) {
// Currently, we are only supporting the high risk alert. If we use want to support other risk levels, we can add them here and use cva to create different variants of this component.
if (severity === "high") {
return (
<div
className={cn(
"flex items-center gap-3.5 bg-[#4A0709] border border-[#FF0006] text-red-400 rounded-xl px-3.5 h-13 text-sm text-white",
className,
)}
>
{icon && <span className="">{icon}</span>}
<span className="font-bold">{title}</span>
<span className="font-normal">{content}</span>
</div>
);
}
return null;
}

View File

@@ -33,7 +33,6 @@ interface ConversationSubscriptionsContextType {
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
socketPath?: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => void;
unsubscribeFromConversation: (conversationId: string) => void;
@@ -96,10 +95,10 @@ export function ConversationSubscriptionsProvider({
[],
);
const unsubscribeFromConversation = useCallback((conversationId: string) => {
// Use functional update to access current socket data and perform cleanup
setConversationSockets((prev) => {
const socketData = prev[conversationId];
const unsubscribeFromConversation = useCallback(
(conversationId: string) => {
// Get a local reference to the socket data to avoid race conditions
const socketData = conversationSockets[conversationId];
if (socketData) {
const { socket } = socketData;
@@ -113,23 +112,24 @@ export function ConversationSubscriptionsProvider({
socket.disconnect();
}
// Update state to remove the socket
setConversationSockets((prev) => {
const newSockets = { ...prev };
delete newSockets[conversationId];
return newSockets;
});
// Remove from active IDs
setActiveConversationIds((prev) =>
prev.filter((id) => id !== conversationId),
);
// Clean up event handler reference
delete eventHandlersRef.current[conversationId];
// Remove the socket from state
const newSockets = { ...prev };
delete newSockets[conversationId];
return newSockets;
}
return prev; // No change if socket not found
});
// Remove from active IDs
setActiveConversationIds((prev) =>
prev.filter((id) => id !== conversationId),
);
}, []);
},
[conversationSockets],
);
const subscribeToConversation = useCallback(
(options: {
@@ -137,17 +137,10 @@ export function ConversationSubscriptionsProvider({
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
socketPath?: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => {
const {
conversationId,
sessionApiKey,
providersSet,
baseUrl,
socketPath,
onEvent,
} = options;
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
options;
// If already subscribed, don't create a new subscription
if (conversationSockets[conversationId]) {
@@ -180,7 +173,9 @@ export function ConversationSubscriptionsProvider({
if (isErrorEvent(event) || isAgentStatusError(event)) {
renderConversationErroredToast(
conversationId,
isErrorEvent(event) ? event.message : "MICROAGENT$UNKNOWN_ERROR",
isErrorEvent(event)
? event.message
: "Unknown error, please try again",
);
} else if (isStatusUpdate(event)) {
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
@@ -204,7 +199,6 @@ export function ConversationSubscriptionsProvider({
// Create socket connection
const socket = io(baseUrl, {
transports: ["websocket"],
path: socketPath ?? "/socket.io",
query: {
conversation_id: conversationId,
session_api_key: sessionApiKey,

View File

@@ -317,24 +317,15 @@ export function WsClientProvider({
session_api_key: conversation.session_api_key, // Have to set here because socketio doesn't support custom headers. :(
};
let baseUrl: string | null = null;
let socketPath: string;
let baseUrl = null;
if (conversation.url && !conversation.url.startsWith("/")) {
const u = new URL(conversation.url);
baseUrl = u.host;
const pathBeforeApi = u.pathname.split("/api/conversations")[0] || "/";
// Socket.IO server default path is /socket.io; prefix with pathBeforeApi for path mode
socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`;
baseUrl = new URL(conversation.url).host;
} else {
baseUrl =
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
window?.location.host;
socketPath = "/socket.io";
baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
}
sio = io(baseUrl, {
transports: ["websocket"],
path: socketPath,
query,
});

View File

@@ -19,8 +19,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
: settings.llm_api_key?.trim() || undefined,
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
condenser_max_size:
settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
provider_tokens_set: settings.PROVIDER_TOKENS_SET,

View File

@@ -1,126 +0,0 @@
import { useMemo } from "react";
import { useRepositoryBranchesPaginated } from "./use-repository-branches";
import { useSearchBranches } from "./use-search-branches";
import { Branch } from "#/types/git";
import { Provider } from "#/types/settings";
export function useBranchData(
repository: string | null,
provider: Provider,
defaultBranch: string | null,
processedSearchInput: string,
inputValue: string,
selectedBranch?: Branch | null,
) {
// Fetch branches with pagination
const {
data: branchData,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
} = useRepositoryBranchesPaginated(repository);
// Search branches when user types
const { data: searchData, isLoading: isSearchLoading } = useSearchBranches(
repository,
processedSearchInput,
30,
provider,
);
// Combine all branches from paginated data
const allBranches = useMemo(
() => branchData?.pages?.flatMap((page) => page.branches) || [],
[branchData],
);
// Check if default branch is in the loaded branches
const defaultBranchInLoaded = useMemo(
() =>
defaultBranch
? allBranches.find((branch) => branch.name === defaultBranch)
: null,
[allBranches, defaultBranch],
);
// Only search for default branch if it's not already in the loaded branches
// and we have loaded some branches (to avoid searching immediately on mount)
const shouldSearchDefaultBranch =
defaultBranch &&
!defaultBranchInLoaded &&
allBranches.length > 0 &&
!processedSearchInput; // Don't search for default branch when user is searching
const { data: defaultBranchData, isLoading: isDefaultBranchLoading } =
useSearchBranches(
repository,
shouldSearchDefaultBranch ? defaultBranch : "",
30,
provider,
);
// Get branches to display with default branch prioritized
const branches = useMemo(() => {
// Don't use search results if input exactly matches selected branch
const shouldUseSearch =
processedSearchInput &&
searchData &&
!(selectedBranch && inputValue === selectedBranch.name);
let branchesToUse = shouldUseSearch ? searchData : allBranches;
// If we have a default branch, ensure it's at the top of the list
if (defaultBranch) {
// Use the already computed defaultBranchInLoaded or check in current branches
let defaultBranchObj = shouldUseSearch
? branchesToUse.find((branch) => branch.name === defaultBranch)
: defaultBranchInLoaded;
// If not found in current branches, check if we have it from the default branch search
if (
!defaultBranchObj &&
defaultBranchData &&
defaultBranchData.length > 0
) {
defaultBranchObj = defaultBranchData.find(
(branch) => branch.name === defaultBranch,
);
// Add the default branch to the beginning of the list
if (defaultBranchObj) {
branchesToUse = [defaultBranchObj, ...branchesToUse];
}
} else if (defaultBranchObj) {
// If found in current branches, move it to the front
const otherBranches = branchesToUse.filter(
(branch) => branch.name !== defaultBranch,
);
branchesToUse = [defaultBranchObj, ...otherBranches];
}
}
return branchesToUse;
}, [
processedSearchInput,
searchData,
allBranches,
selectedBranch,
inputValue,
defaultBranch,
defaultBranchInLoaded,
defaultBranchData,
]);
return {
branches,
allBranches,
fetchNextPage,
hasNextPage,
isLoading: isLoading || isDefaultBranchLoading,
isFetchingNextPage,
isError,
isSearchLoading,
};
}

View File

@@ -1,27 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useMicroagentManagementConversations = (
selectedRepository: string,
pageId?: string,
limit: number = 100,
cacheDisabled: boolean = false,
) =>
useQuery({
queryKey: [
"conversations",
"microagent-management",
pageId,
limit,
selectedRepository,
],
queryFn: () =>
OpenHands.getMicroagentManagementConversations(
selectedRepository,
pageId,
limit,
),
enabled: !!selectedRepository,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
});

View File

@@ -1,46 +1,14 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { Branch, PaginatedBranchesResponse } from "#/types/git";
import { Branch } from "#/types/git";
export const useRepositoryBranches = (repository: string | null) =>
useQuery<Branch[]>({
queryKey: ["repository", repository, "branches"],
queryFn: async () => {
if (!repository) return [];
const response = await OpenHands.getRepositoryBranches(repository);
// Ensure we return an array even if the response is malformed
return Array.isArray(response.branches) ? response.branches : [];
return OpenHands.getRepositoryBranches(repository);
},
enabled: !!repository,
staleTime: 1000 * 60 * 5, // 5 minutes
});
export const useRepositoryBranchesPaginated = (
repository: string | null,
perPage: number = 30,
) =>
useInfiniteQuery<PaginatedBranchesResponse, Error>({
queryKey: ["repository", repository, "branches", "paginated", perPage],
queryFn: async ({ pageParam = 1 }) => {
if (!repository) {
return {
branches: [],
has_next_page: false,
current_page: 1,
per_page: perPage,
total_count: 0,
};
}
return OpenHands.getRepositoryBranches(
repository,
pageParam as number,
perPage,
);
},
enabled: !!repository,
staleTime: 1000 * 60 * 5, // 5 minutes
getNextPageParam: (lastPage) =>
// Use the has_next_page flag from the API response
lastPage.has_next_page ? lastPage.current_page + 1 : undefined,
initialPageParam: 1,
});

View File

@@ -1,35 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { Branch } from "#/types/git";
import { Provider } from "#/types/settings";
export function useSearchBranches(
repository: string | null,
query: string,
perPage: number = 30,
selectedProvider?: Provider,
) {
return useQuery<Branch[]>({
queryKey: [
"repository",
repository,
"branches",
"search",
query,
perPage,
selectedProvider,
],
queryFn: async () => {
if (!repository || !query) return [];
return OpenHands.searchRepositoryBranches(
repository,
query,
perPage,
selectedProvider,
);
},
enabled: !!repository && !!query,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 15,
});
}

View File

@@ -5,16 +5,11 @@ import { Provider } from "#/types/settings";
export function useSearchRepositories(
query: string,
selectedProvider?: Provider | null,
pageSize: number = 3,
) {
return useQuery({
queryKey: ["repositories", "search", query, selectedProvider, pageSize],
queryKey: ["repositories", "search", query, selectedProvider],
queryFn: () =>
OpenHands.searchGitRepositories(
query,
pageSize,
selectedProvider || undefined,
),
OpenHands.searchGitRepositories(query, 3, selectedProvider || undefined),
enabled: !!query && !!selectedProvider,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes

View File

@@ -22,8 +22,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
CONDENSER_MAX_SIZE:
apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
apiSettings.enable_proactive_conversation_starters,

View File

@@ -1,27 +1,14 @@
import React from "react";
import { useQueries, type Query } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { AxiosError } from "axios";
import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { renderConversationStartingToast } from "#/components/features/chat/microagent/microagent-status-toast";
interface ConversationData {
conversationId: string;
sessionApiKey: string | null;
baseUrl: string;
socketPath: string;
onEventCallback?: (event: unknown, conversationId: string) => void;
}
import { CreateMicroagent } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
* This version waits for conversation status to be "RUNNING" before establishing WebSocket connection.
* Shows immediate toast feedback and polls conversation status until ready.
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
* multiple conversations simultaneously.
*/
export const useCreateConversationAndSubscribeMultiple = () => {
const { mutate: createConversation, isPending } = useCreateConversation();
@@ -33,88 +20,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
activeConversationIds,
} = useConversationSubscriptions();
// Store conversation data immediately after creation
const [createdConversations, setCreatedConversations] = React.useState<
Record<string, ConversationData>
>({});
// Get conversation IDs that need polling
const conversationIdsToWatch = Object.keys(createdConversations);
// Poll each conversation until it's ready
const conversationQueries = useQueries({
queries: conversationIdsToWatch.map((conversationId) => ({
queryKey: ["conversation-ready-poll", conversationId],
queryFn: () => OpenHands.getConversation(conversationId),
enabled: !!conversationId,
refetchInterval: (query: Query<Conversation | null, AxiosError>) => {
const status = query.state.data?.status;
if (status === "STARTING") {
return 3000; // Poll every 3 seconds while STARTING
}
return false; // Stop polling once not STARTING
},
retry: false,
})),
});
// Extract stable values from queries for dependency array
const queryStatuses = conversationQueries.map((query) => query.data?.status);
const queryDataExists = conversationQueries.map((query) => !!query.data);
// Effect to handle subscription when conversations are ready
React.useEffect(() => {
conversationQueries.forEach((query, index) => {
const conversationId = conversationIdsToWatch[index];
const conversationData = createdConversations[conversationId];
if (!query.data || !conversationData) return;
const { status, url, session_api_key: sessionApiKey } = query.data;
let { baseUrl } = conversationData;
if (url && !url.startsWith("/")) {
baseUrl = new URL(url).host;
}
if (status === "RUNNING") {
// Conversation is ready - subscribe to WebSocket
subscribeToConversation({
conversationId,
sessionApiKey,
providersSet: providers,
baseUrl,
socketPath: conversationData.socketPath,
onEvent: conversationData.onEventCallback,
});
// Remove from created conversations (cleanup)
setCreatedConversations((prev) => {
const newCreated = { ...prev };
delete newCreated[conversationId];
return newCreated;
});
} else if (status === "STOPPED") {
// Dismiss the starting toast
toast.dismiss(`starting-${conversationId}`);
// Remove from created conversations (cleanup)
setCreatedConversations((prev) => {
const newCreated = { ...prev };
delete newCreated[conversationId];
return newCreated;
});
}
});
}, [
queryStatuses,
queryDataExists,
conversationIdsToWatch,
createdConversations,
subscribeToConversation,
providers,
]);
const createConversationAndSubscribe = React.useCallback(
({
query,
@@ -128,7 +33,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
conversationInstructions: string;
repository: {
name: string;
branch?: string;
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
@@ -144,46 +49,33 @@ export const useCreateConversationAndSubscribeMultiple = () => {
},
{
onSuccess: (data) => {
// Show immediate toast to let user know something is happening
renderConversationStartingToast(data.conversation_id);
// Call the success callback immediately
if (onSuccessCallback) {
onSuccessCallback(data.conversation_id);
}
// Only handle immediate post-creation tasks here
let baseUrl = "";
let socketPath: string;
if (data?.url && !data.url.startsWith("/")) {
const u = new URL(data.url);
baseUrl = u.host;
const pathBeforeApi =
u.pathname.split("/api/conversations")[0] || "/";
socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`;
baseUrl = new URL(data.url).host;
} else {
baseUrl =
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
window?.location.host;
socketPath = "/socket.io";
}
// Store conversation data for polling and eventual subscription
setCreatedConversations((prev) => ({
...prev,
[data.conversation_id]: {
conversationId: data.conversation_id,
sessionApiKey: data.session_api_key,
baseUrl,
socketPath,
onEventCallback,
},
}));
// Subscribe to the conversation
subscribeToConversation({
conversationId: data.conversation_id,
sessionApiKey: data.session_api_key,
providersSet: providers,
baseUrl,
onEvent: onEventCallback,
});
// Call the success callback if provided
if (onSuccessCallback) {
onSuccessCallback(data.conversation_id);
}
},
},
);
},
[createConversation],
[createConversation, subscribeToConversation, providers],
);
return {

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