Compare commits

..

14 Commits

Author SHA1 Message Date
openhands 57d07df83f Implement FE methods for GitHub installations and BitBucket workspaces 2025-07-21 20:05:26 +00:00
rohitvinodmalhotra@gmail.com 8f594310fa add endpoints for installations 2025-07-21 15:54:12 -04:00
rohitvinodmalhotra@gmail.com a41280f3b8 add provider methods for getting installations 2025-07-21 15:50:55 -04:00
rohitvinodmalhotra@gmail.com 385accd267 add bitbucket workspaces 2025-07-21 15:21:22 -04:00
rohitvinodmalhotra@gmail.com 72a462681c add app installation hook 2025-07-21 15:19:19 -04:00
openhands 27caa0b5fd Remove repository filtering by provider for now 2025-07-21 19:08:34 +00:00
openhands e8cabe6def Add provider dropdown to repository selection 2025-07-21 19:04:32 +00:00
rohitvinodmalhotra@gmail.com 8aa1ae712f update mock 2025-07-21 12:30:17 -04:00
rohitvinodmalhotra@gmail.com 993804dd4f fix tests 2025-07-21 12:25:08 -04:00
rohitvinodmalhotra@gmail.com d5aaa8a67a add params to endpoint 2025-07-21 12:19:49 -04:00
rohitvinodmalhotra@gmail.com 9cc7823723 fix type + get bitbucket installations 2025-07-21 11:57:14 -04:00
openhands def357024e Add get_paginated_repos method to BitBucketService 2025-07-21 15:48:52 +00:00
rohitvinodmalhotra@gmail.com d868eb5dee paginate repos for gitlab 2025-07-21 11:35:10 -04:00
rohitvinodmalhotra@gmail.com a88f3744da add method for paginated response 2025-07-21 11:20:45 -04:00
112 changed files with 973 additions and 6008 deletions
+21 -100
View File
@@ -1,135 +1,56 @@
# Run evaluation on a PR, after releases, or manually
# Run evaluation on a PR
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
# Runs when a PR is labeled with one of the "run-eval-" labels
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout branch
- name: Checkout PR branch
uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
ref: ${{ github.head_ref }}
- name: Set evaluation parameters
id: eval_params
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on issue/PR
- name: Comment on PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
Running evaluation on the PR. Once eval is done, the results will be posted.
+10 -13
View File
@@ -1,32 +1,29 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running additional frontend checks..."
echo "Frontend changes detected. Running frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
@@ -53,7 +50,7 @@ if [ -n "$frontend_changes" ]; then
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping additional frontend checks."
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
+1 -1
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.50-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.49-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
+1 -1
View File
@@ -12,7 +12,7 @@ services:
- SANDBOX_API_HOSTNAME=host.docker.internal
- DOCKER_HOST_ADDR=host.docker.internal
#
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.50-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.49-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -7,7 +7,7 @@ services:
image: openhands:latest
container_name: openhands-app-${DATE:-}
environment:
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+2 -2
View File
@@ -103,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -112,7 +112,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
python -m openhands.cli.main --override-cli-mode true
```
+2 -2
View File
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
+4 -4
View File
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.50
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.49
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
+3 -3
View File
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
@@ -117,7 +117,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -345,7 +345,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -226,7 +226,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -203,7 +203,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -164,7 +164,6 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -19,13 +19,7 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>,
);
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -41,13 +35,7 @@ describe("AuthModal", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -64,6 +52,7 @@ describe("AuthModal", () => {
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
@@ -16,6 +16,8 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
@@ -122,8 +124,7 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -131,8 +132,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -141,32 +140,15 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
// Simulate context menu being opened by parent
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -175,18 +157,18 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
@@ -216,11 +198,7 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
let menuOpen = true;
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
menuOpen = isOpen;
});
const { rerender } = renderWithProviders(
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -228,27 +206,10 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
await clickOnEditButton(user);
// Re-render with updated state
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
@@ -266,7 +227,6 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -275,8 +235,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -313,7 +271,6 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -322,8 +279,6 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -337,7 +292,6 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -346,11 +300,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
@@ -360,7 +315,7 @@ describe("ConversationCard", () => {
});
it("should show display cost button only when showOptions is true", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -369,17 +324,21 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
@@ -389,11 +348,12 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
@@ -401,7 +361,6 @@ describe("ConversationCard", () => {
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -411,11 +370,12 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
@@ -426,7 +386,7 @@ describe("ConversationCard", () => {
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const onContextMenuToggle = vi.fn();
const user = userEvent.setup();
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
@@ -434,15 +394,19 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
@@ -450,11 +414,10 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),
+26 -22
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.50.0",
"version": "0.49.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.50.0",
"version": "0.49.0",
"dependencies": {
"@heroui/react": "^2.8.1",
"@microlink/react-json-view": "^1.26.2",
@@ -34,7 +34,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -68,7 +68,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -82,11 +82,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
@@ -6160,9 +6160,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"version": "24.0.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
"integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.8.0"
@@ -9017,10 +9017,11 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9082,10 +9083,11 @@
}
},
"node_modules/eslint-plugin-i18next": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz",
"integrity": "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
"dev": true,
"license": "ISC",
"dependencies": {
"lodash": "^4.17.21",
"requireindex": "~1.1.0"
@@ -9250,10 +9252,11 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz",
"integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==",
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz",
"integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
@@ -14265,9 +14268,10 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.257.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.1.tgz",
"integrity": "sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==",
"version": "1.257.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.50.0",
"version": "0.49.0",
"private": true,
"type": "module",
"engines": {
@@ -33,7 +33,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.0.14",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,11 +106,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
+18 -42
View File
@@ -13,13 +13,11 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -252,28 +250,6 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -285,7 +261,6 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -294,7 +269,6 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(
@@ -490,22 +464,6 @@ class OpenHands {
return data;
}
/**
* Get the available microagents for a specific repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -531,6 +489,24 @@ class OpenHands {
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;
+1 -12
View File
@@ -79,11 +79,7 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
conversation_id: string;
@@ -98,7 +94,6 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -138,9 +133,3 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}
@@ -13,7 +13,6 @@ interface ControlsProps {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
@@ -38,8 +37,6 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={setContextMenuOpen}
/>
</div>
);
@@ -35,8 +35,6 @@ interface ConversationCardProps {
conversationStatus?: ConversationStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
@@ -57,11 +55,10 @@ export function ConversationCard({
conversationStatus = "STOPPED",
variant = "default",
conversationId,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
@@ -104,21 +101,21 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDownloadViaVSCode = async (
@@ -144,7 +141,7 @@ export function ConversationCard({
}
}
onContextMenuToggle?.(false);
setContextMenuVisible(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -227,15 +224,15 @@ export function ConversationCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenuToggle?.(!contextMenuOpen);
setContextMenuVisible((prev) => !prev);
}}
/>
</div>
)}
<div className="relative">
{contextMenuOpen && (
{contextMenuVisible && (
<ConversationCardContextMenu
onClose={() => onContextMenuToggle?.(false)}
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
@@ -36,9 +36,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
@@ -147,10 +144,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
/>
)}
</NavLink>
@@ -10,6 +10,9 @@ import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
import {
RepositoryDropdown,
RepositoryLoadingState,
@@ -32,8 +35,10 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const { providers } = useUserProviders();
const {
data: repositories,
isLoading: isLoadingRepositories,
@@ -56,6 +61,13 @@ export function RepositorySelectionForm({
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select provider if there's only one
React.useEffect(() => {
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [providers, selectedProvider]);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
React.useEffect(() => {
if (
@@ -83,8 +95,10 @@ export function RepositorySelectionForm({
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
// Use all repositories without filtering by provider for now
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
const repositoriesItems = (allRepositories || []).map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
@@ -94,6 +108,14 @@ export function RepositorySelectionForm({
label: branch.name,
}));
// Create provider dropdown items
const providerItems = React.useMemo(() => {
return providers.map(provider => ({
key: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
}));
}, [providers]);
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
@@ -102,6 +124,14 @@ export function RepositorySelectionForm({
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
};
const handleProviderSelection = (key: React.Key | null) => {
const provider = key as Provider | null;
setSelectedProvider(provider);
setSelectedRepository(null); // Reset repository selection when provider changes
setSelectedBranch(null); // Reset branch selection when provider changes
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
@@ -133,6 +163,26 @@ export function RepositorySelectionForm({
}
};
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
}
return (
<SettingsDropdownInput
testId="provider-dropdown"
name="provider-dropdown"
placeholder="Select Provider"
items={providerItems}
wrapperClassName="max-w-[500px]"
onSelectionChange={handleProviderSelection}
selectedKey={selectedProvider || undefined}
/>
);
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
@@ -143,11 +193,15 @@ export function RepositorySelectionForm({
return <RepositoryErrorState />;
}
// For now, don't disable the repo dropdown based on provider selection
const isDisabled = false;
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
isDisabled={isDisabled}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
@@ -195,8 +249,8 @@ export function RepositorySelectionForm({
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton
@@ -1,8 +1,7 @@
import React, { ReactNode } from "react";
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -10,8 +9,6 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -20,8 +17,6 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -31,12 +26,11 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}
@@ -1,19 +1,12 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
export function BranchErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>
@@ -1,22 +1,13 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
export function BranchLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
@@ -8,6 +8,7 @@ export interface RepositoryDropdownProps {
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
isDisabled?: boolean;
}
export function RepositoryDropdown({
@@ -15,6 +16,7 @@ export function RepositoryDropdown({
onSelectionChange,
onInputChange,
defaultFilter,
isDisabled = false,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
@@ -22,12 +24,13 @@ export function RepositoryDropdown({
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
isDisabled={isDisabled}
/>
);
}
@@ -1,26 +0,0 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}
@@ -1,20 +1,10 @@
import { useTranslation } from "react-i18next";
import { useDispatch, useSelector } from "react-redux";
import { I18nKey } from "#/i18n/declaration";
import {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
export function MicroagentManagementAddMicroagentButton() {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
@@ -23,10 +13,8 @@ export function MicroagentManagementAddMicroagentButton({
const dispatch = useDispatch();
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const handleClick = () => {
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,155 +10,30 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: (formData: MicroagentFormData) => void;
onConfirm: () => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
@@ -189,7 +64,6 @@ export function MicroagentManagementAddMicroagentModal({
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"
@@ -199,8 +73,6 @@ export function MicroagentManagementAddMicroagentModal({
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
@@ -208,6 +80,19 @@ export function MicroagentManagementAddMicroagentModal({
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
<div className="flex items-center gap-2 text-[11px] font-normal text-white leading-[16px]">
<span className="font-semibold">
{t(I18nKey.COMMON$FOR_EXAMPLE)}:
</span>
<span className="underline">
{t(I18nKey.COMMON$TEST_DB_MIGRATION)}
</span>
<span className="underline">{t(I18nKey.COMMON$RUN_TEST)}</span>
<span className="underline">{t(I18nKey.COMMON$RUN_APP)}</span>
<span className="underline">
{t(I18nKey.COMMON$LEARN_FILE_STRUCTURE)}
</span>
</div>
</label>
<label
htmlFor="trigger-input"
@@ -244,26 +129,17 @@ export function MicroagentManagementAddMicroagentModal({
type="button"
variant="secondary"
onClick={onCancel}
testId="cancel-button"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
onClick={onConfirm}
data-testid="confirm-button"
>
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
{t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
@@ -1,230 +0,0 @@
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
// Responsive width state
const [width, setWidth] = useState(window.innerWidth);
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
function handleResize() {
setWidth(window.innerWidth);
}
useEffect(() => {
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
if (width < 1024) {
return (
<div className="w-full h-full flex flex-col gap-6">
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
<MicroagentManagementSidebar isSmallerScreen />
</div>
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
<MicroagentManagementSidebar />
<div className="flex-1">
<MicroagentManagementMain />
</div>
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementConversationStopped() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -1,19 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementDefault() {
const { t } = useTranslation();
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
@@ -1,44 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementError() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -1,52 +1,29 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { MicroagentManagementDefault } from "./microagent-management-default";
import { MicroagentManagementOpeningPr } from "./microagent-management-opening-pr";
import { MicroagentManagementReviewPr } from "./microagent-management-review-pr";
import { MicroagentManagementViewMicroagent } from "./microagent-management-view-microagent";
import { MicroagentManagementError } from "./microagent-management-error";
import { MicroagentManagementConversationStopped } from "./microagent-management-conversation-stopped";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementMain() {
const { selectedMicroagentItem } = useSelector(
const { t } = useTranslation();
const { selectedMicroagent } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent, conversation } = selectedMicroagentItem ?? {};
if (microagent) {
return <MicroagentManagementViewMicroagent />;
if (!selectedMicroagent) {
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
</div>
<div className="text-white text-sm font-normal text-center max-w-[455px]">
{t(
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
)}
</div>
</div>
);
}
if (conversation) {
if (conversation.pr_number && conversation.pr_number.length > 0) {
return <MicroagentManagementReviewPr />;
}
const isConversationStarting =
conversation.status === "STARTING" ||
conversation.runtime_status === "STATUS$STARTING_RUNTIME";
const isConversationOpeningPr =
conversation.status === "RUNNING" &&
conversation.runtime_status === "STATUS$READY";
if (isConversationStarting || isConversationOpeningPr) {
return <MicroagentManagementOpeningPr />;
}
if (conversation.runtime_status === "STATUS$ERROR") {
return <MicroagentManagementError />;
}
if (
conversation.status === "STOPPED" ||
conversation.runtime_status === "STATUS$STOPPED"
) {
return <MicroagentManagementConversationStopped />;
}
return <MicroagentManagementDefault />;
}
return <MicroagentManagementDefault />;
return null;
}
@@ -1,142 +1,32 @@
import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
setSelectedMicroagentItem,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import { GitRepository } from "#/types/git";
export interface Microagent {
id: string;
name: string;
repositoryUrl: string;
createdAt: string;
}
interface MicroagentManagementMicroagentCardProps {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
repository: GitRepository;
microagent: Microagent;
}
export function MicroagentManagementMicroagentCard({
microagent,
conversation,
repository,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const {
status: conversationStatus,
runtime_status: runtimeStatus,
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STARTING" ||
runtimeStatus === "STATUS$STARTING_RUNTIME"
) {
return t(I18nKey.COMMON$STARTING);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
const cardTitle = microagent?.name ?? conversation?.title;
const isCardSelected = useMemo(() => {
if (microagent && selectedMicroagentItem?.microagent) {
return selectedMicroagentItem.microagent.name === microagent.name;
}
if (conversation && selectedMicroagentItem?.conversation) {
return (
selectedMicroagentItem.conversation.conversation_id ===
conversation.conversation_id
);
}
return false;
}, [microagent, conversation, selectedMicroagentItem]);
const onMicroagentCardClicked = () => {
dispatch(
setSelectedMicroagentItem(
microagent
? {
microagent,
conversation: null,
}
: {
microagent: null,
conversation,
},
),
);
dispatch(setSelectedRepository(repository));
};
return (
<div
className={cn(
"rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300",
isCardSelected && "bg-[#ffffff33] border-[#C9B974]",
)}
onClick={onMicroagentCardClicked}
>
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">
{microagent.repositoryUrl}
</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
</div>
</div>
);
@@ -0,0 +1,38 @@
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export function MicroagentManagementMicroagents() {
const microagents = [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
{
id: "tell-me-a-joke",
name: "Tell me a joke",
repositoryUrl: ".openhands/microagents/Repo Overview",
createdAt: "05/30/2025",
},
];
const numberOfMicroagents = microagents.length;
if (numberOfMicroagents === 0) {
return null;
}
return (
<div>
<div className="flex items-center justify-end pb-4">
<MicroagentManagementAddMicroagentButton />
</div>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</div>
);
}
@@ -1,22 +0,0 @@
import { FaCircleInfo } from "react-icons/fa6";
interface MicroagentManagementNoRepositoriesProps {
title: string;
documentationUrl: string;
}
export function MicroagentManagementNoRepositories({
title,
documentationUrl,
}: MicroagentManagementNoRepositoriesProps) {
return (
<div className="flex items-center justify-center pt-10">
<div className="flex items-center gap-2">
<h2 className="text-white text-sm font-medium">{title}</h2>
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
<FaCircleInfo className="text-primary" />
</a>
</div>
</div>
);
}
@@ -1,47 +0,0 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { Loader } from "#/components/shared/loader";
export function MicroagentManagementOpeningPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const { conversation_id: conversationId } = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-semibold pb-2">
{t(I18nKey.COMMON$WORKING_ON_IT)}!
</div>
<div className="text-[#ffffff99] text-[18px] font-normal text-center max-w-[518px] pb-[22px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT)}
</div>
<Loader size="small" className="pb-[22px]" />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
</div>
);
}
@@ -0,0 +1,49 @@
import {
Microagent,
MicroagentManagementMicroagentCard,
} from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
microagents: Microagent[];
}
interface MicroagentManagementRepoMicroagentProps {
repoMicroagent: RepoMicroagent;
}
export function MicroagentManagementRepoMicroagent({
repoMicroagent,
}: MicroagentManagementRepoMicroagentProps) {
const { microagents } = repoMicroagent;
const numberOfMicroagents = microagents.length;
return (
<div className="pb-12">
<div className="flex items-center justify-between pb-4">
<div className="text-white text-base font-normal">
{repoMicroagent.repositoryName}
</div>
<MicroagentManagementAddMicroagentButton />
</div>
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{numberOfMicroagents > 0 && (
<>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</>
)}
</div>
);
}
@@ -1,122 +1,42 @@
import { useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl } from "#/utils/utils";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
import { MicroagentManagementRepoMicroagent } from "./microagent-management-repo-microagent";
interface MicroagentManagementRepoMicroagentsProps {
repository: GitRepository;
}
export function MicroagentManagementRepoMicroagents() {
const repoMicroagents = [
{
id: "rbren/rss-parser",
repositoryName: "rbren/rss-parser",
repositoryUrl: "https://github.com/rbren/rss-parser",
microagents: [],
},
{
id: "fairwinds/polaris",
repositoryName: "fairwinds/polaris",
repositoryUrl: "https://github.com/fairwinds/polaris",
microagents: [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
],
},
];
export function MicroagentManagementRepoMicroagents({
repository,
}: MicroagentManagementRepoMicroagentsProps) {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const numberOfRepoMicroagents = repoMicroagents.length;
const dispatch = useDispatch();
const { full_name: repositoryName, git_provider: gitProvider } = repository;
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
const repositoryUrl = `${getGitProviderBaseUrl(gitProvider)}/${repositoryName}`;
const {
data: microagents,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(repositoryName, "microagent_management", 1000);
useEffect(() => {
const hasConversations = conversations && conversations.length > 0;
const selectedConversation = selectedMicroagentItem?.conversation;
if (hasConversations && selectedConversation) {
// get the latest selected conversation.
const latestSelectedConversation = conversations.find(
(conversation) =>
conversation.conversation_id === selectedConversation.conversation_id,
);
if (latestSelectedConversation) {
dispatch(
setSelectedMicroagentItem({
microagent: null,
conversation: latestSelectedConversation,
}),
);
}
}
}, [conversations]);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<LoadingSpinner size="small" />
</div>
);
if (numberOfRepoMicroagents === 0) {
return null;
}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo repositoryUrl={repositoryUrl} />
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={microagent}
repository={repository}
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
conversation={conversation}
repository={repository}
/>
</div>
))}
<div>
{repoMicroagents.map((repoMicroagent) => (
<MicroagentManagementRepoMicroagent
key={repoMicroagent.id}
repoMicroagent={repoMicroagent}
/>
))}
</div>
);
}
@@ -1,119 +0,0 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "repositories") {
return (
<MicroagentManagementNoRepositories
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "organizations") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
)}
documentationUrl={
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
}
/>
);
}
}
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents repository={repository} />
</AccordionItem>
))}
</Accordion>
</div>
);
}
@@ -1,74 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructPullRequestUrl } from "#/utils/utils";
import { Provider } from "#/types/settings";
import { RootState } from "#/store";
export function MicroagentManagementReviewPr() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { conversation } = selectedMicroagentItem ?? {};
const {
conversation_id: conversationId,
selected_repository: selectedRepository,
git_provider: gitProvider,
pr_number: prNumber,
} = conversation ?? {};
if (!conversationId) {
return null;
}
return (
<div className="flex-1 flex flex-col h-full items-center justify-center">
<div className="text-[#ffffff99] text-[22px] font-bold pb-[22px] text-center max-w-[455px]">
{t(I18nKey.MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY)}
</div>
<div className="flex gap-[22px]">
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="secondary"
testId="view-conversation-button"
>
{t(I18nKey.MICROAGENT$VIEW_CONVERSATION)}
</BrandButton>
</a>
<a
href={
selectedRepository && gitProvider && prNumber && prNumber.length > 0
? constructPullRequestUrl(
prNumber[0],
gitProvider,
selectedRepository,
)
: "/#"
}
target="_blank"
rel="noopener noreferrer"
>
<BrandButton
type="button"
variant="primary"
testId="view-conversation-button"
>
{`${t(I18nKey.COMMON$REVIEW_PR_IN)} ${getProviderName(
gitProvider as Provider,
)}`}
</BrandButton>
</a>
</div>
</div>
);
}
@@ -1,7 +1,6 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import QuestionCircleIcon from "#/icons/question-circle.svg?react";
import { DOCUMENTATION_URL } from "#/utils/constants";
export function MicroagentManagementSidebarHeader() {
const { t } = useTranslation();
@@ -13,13 +12,7 @@ export function MicroagentManagementSidebarHeader() {
</h1>
<p className="text-white text-sm font-normal leading-[20px] pt-2">
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
<a
href={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
target="_blank"
rel="noopener noreferrer"
>
<QuestionCircleIcon className="inline-block ml-1" />
</a>
<QuestionCircleIcon className="inline-block ml-1" />
</p>
</div>
);
@@ -1,16 +1,12 @@
import { Tab, Tabs } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
import { MicroagentManagementMicroagents } from "./microagent-management-microagents";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
useSelector((state: RootState) => state.microagentManagement);
return (
<div className="flex w-full flex-col">
<Tabs
@@ -21,27 +17,18 @@ export function MicroagentManagementSidebarTabs() {
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
tab: "px-2 h-[22px]",
tabContent: "text-white text-[12px] font-normal",
panel: "p-0",
panel: "py-0",
cursor: "bg-[#C9B97480] rounded-sm",
}}
>
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
/>
<MicroagentManagementMicroagents />
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
/>
<MicroagentManagementRepoMicroagents />
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
/>
<MicroagentManagementMicroagents />
</Tab>
</Tabs>
</div>
@@ -1,71 +1,11 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { cn } from "#/utils/utils";
interface MicroagentManagementSidebarProps {
isSmallerScreen?: boolean;
}
export function MicroagentManagementSidebar({
isSmallerScreen = false,
}: MicroagentManagementSidebarProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
}, [repositories, dispatch]);
export function MicroagentManagementSidebar() {
return (
<div
className={cn(
"w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col",
isSmallerScreen && "w-full border-none",
)}
>
<div className="w-[418px] h-full border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6">
<MicroagentManagementSidebarHeader />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<LoadingSpinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
)}
<MicroagentManagementSidebarTabs />
</div>
);
}
@@ -1,73 +0,0 @@
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
export function MicroagentManagementViewMicroagentContent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
</div>
);
}
@@ -1,60 +0,0 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RootState } from "#/store";
import { BrandButton } from "../settings/brand-button";
import { getProviderName, constructMicroagentUrl } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
export function MicroagentManagementViewMicroagentHeader() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
// Construct the microagent URL
const microagentUrl = constructMicroagentUrl(
selectedRepository.git_provider,
selectedRepository.full_name,
microagent.path,
);
return (
<div className="flex items-center justify-between pb-2">
<span className="text-sm text-[#ffffff99]">
{selectedRepository.full_name}
</span>
<div className="flex items-center justify-end gap-2">
<a href={microagentUrl} target="_blank" rel="noopener noreferrer">
<BrandButton
type="button"
variant="secondary"
testId="edit-in-git-button"
className="py-1 px-2"
>
{`${t(I18nKey.COMMON$EDIT_IN)} ${getProviderName(selectedRepository.git_provider)}`}
</BrandButton>
</a>
<BrandButton
type="button"
variant="primary"
onClick={() => {}}
testId="learn-button"
className="py-1 px-2"
>
{t(I18nKey.COMMON$LEARN)}
</BrandButton>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { MicroagentManagementViewMicroagentHeader } from "./microagent-management-view-microagent-header";
import { MicroagentManagementViewMicroagentContent } from "./microagent-management-view-microagent-content";
export function MicroagentManagementViewMicroagent() {
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const { microagent } = selectedMicroagentItem ?? {};
if (!microagent || !selectedRepository) {
return null;
}
return (
<div className="flex flex-col w-full h-full p-6 overflow-auto">
<MicroagentManagementViewMicroagentHeader />
<span className="text-white text-2xl font-medium pb-2">
{microagent.name}
</span>
<span className="text-white text-lg font-medium pb-6">
{microagent.path}
</span>
<div className="flex-1">
<MicroagentManagementViewMicroagentContent />
</div>
</div>
);
}
@@ -2,7 +2,6 @@ import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
import { TooltipButton } from "./tooltip-button";
import { I18nKey } from "#/i18n/declaration";
import { useConfig } from "#/hooks/query/use-config";
interface SettingsButtonProps {
onClick?: () => void;
@@ -14,12 +13,6 @@ export function SettingsButton({
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
// Determine the correct settings path based on app mode
// In SaaS mode, navigate directly to user settings to avoid the LLM settings page
const settingsPath =
config?.APP_MODE === "saas" ? "/settings/user" : "/settings";
return (
<TooltipButton
@@ -27,7 +20,7 @@ export function SettingsButton({
tooltip={t(I18nKey.SETTINGS$TITLE)}
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo={settingsPath}
navLinkTo="/settings"
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
@@ -1,16 +0,0 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { Provider } from "#/types/settings";
interface GitProviderIconProps {
gitProvider: Provider;
}
export function GitProviderIcon({ gitProvider }: GitProviderIconProps) {
return (
<>
{gitProvider === "github" && <FaGithub size={14} />}
{gitProvider === "gitlab" && <FaGitlab />}
{gitProvider === "bitbucket" && <FaBitbucket />}
</>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { cn } from "#/utils/utils";
interface LoaderProps {
size?: "small" | "medium" | "large";
className?: string;
}
export function Loader({ size = "medium", className }: LoaderProps) {
const sizeClasses = {
small: "w-3 h-3",
medium: "w-4 h-4",
large: "w-5 h-5",
};
const dotSize = sizeClasses[size];
return (
<div
data-testid="loader"
className={cn("flex items-center justify-center", className)}
>
<div className={cn("loader rounded-full", dotSize)} />
</div>
);
}
@@ -3,7 +3,6 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
interface CreateConversationVariables {
query?: string;
@@ -14,7 +13,6 @@ interface CreateConversationVariables {
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
}
export const useCreateConversation = () => {
@@ -23,13 +21,8 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
const {
query,
repository,
suggestedTask,
conversationInstructions,
createMicroagent,
} = variables;
const { query, repository, suggestedTask, conversationInstructions } =
variables;
return OpenHands.createConversation(
repository?.name,
@@ -38,7 +31,6 @@ export const useCreateConversation = () => {
suggestedTask,
repository?.branch,
conversationInstructions,
createMicroagent,
);
},
onSuccess: async (_, { query, repository }) => {
@@ -0,0 +1,23 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useAppInstallations = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
queryFn: OpenHands.getGitHubUserInstallationIds,
enabled:
userIsAuthenticated &&
providers.includes("github") &&
!!config?.GITHUB_CLIENT_ID &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
export const useBitbucketWorkspaces = () => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["workspaces", providers],
queryFn: OpenHands.getBitBucketWorkspaces,
enabled:
userIsAuthenticated &&
providers.includes("bitbucket") &&
config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};
@@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useRepositoryMicroagents = (owner: string, repo: string) =>
useQuery({
queryKey: ["repository", "microagents", owner, repo],
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
enabled: !!owner && !!repo,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -1,26 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
OpenHands.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -3,7 +3,6 @@ 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 } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
@@ -25,7 +24,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
onSuccessCallback,
onEventCallback,
}: {
@@ -36,7 +34,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
@@ -45,7 +42,6 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
},
{
onSuccess: (data) => {
-18
View File
@@ -12,7 +12,6 @@ export enum I18nKey {
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_OPENING_PR = "MICROAGENT$STATUS_OPENING_PR",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
@@ -709,21 +708,4 @@ export enum I18nKey {
COMMON$RUN_TEST = "COMMON$RUN_TEST",
COMMON$RUN_APP = "COMMON$RUN_APP",
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
COMMON$READY_FOR_REVIEW = "COMMON$READY_FOR_REVIEW",
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
COMMON$WORKING_ON_IT = "COMMON$WORKING_ON_IT",
MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT = "MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT",
MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY = "MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY",
COMMON$REVIEW_PR_IN = "COMMON$REVIEW_PR_IN",
COMMON$EDIT_IN = "COMMON$EDIT_IN",
COMMON$LEARN = "COMMON$LEARN",
COMMON$STARTING = "COMMON$STARTING",
MICROAGENT_MANAGEMENT$ERROR = "MICROAGENT_MANAGEMENT$ERROR",
MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED = "MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED",
}
+1 -289
View File
@@ -191,22 +191,6 @@
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_OPENING_PR": {
"en": "Opening PR",
"ja": "PRを開いています",
"zh-CN": "正在打开PR",
"zh-TW": "正在打開PR",
"ko-KR": "PR 열는 중",
"no": "Åpner PR",
"it": "Apertura PR",
"pt": "Abrindo PR",
"es": "Abriendo PR",
"ar": "فتح PR",
"fr": "Ouverture de la PR",
"tr": "PR açılıyor",
"de": "PR wird geöffnet",
"uk": "Відкриття PR"
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
@@ -11213,7 +11197,7 @@
"fr": "Que souhaitez-vous que le microagent fasse ?",
"tr": "Mikro ajanın ne yapmasını istersiniz?",
"de": "Was soll der Microagent tun?",
"uk": "Що в,и хочете, щоб зробив мікроагент?"
"uk": "Що ви хочете, щоб зробив мікроагент?"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
"en": "Describe what you would like the Microagent to do.",
@@ -11342,277 +11326,5 @@
"tr": "Dosya yapısını öğren",
"de": "Dateistruktur lernen",
"uk": "Вивчити структуру файлів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": {
"en": "You do not have user-level microagents",
"ja": "ユーザーレベルのマイクロエージェントがありません",
"zh-CN": "您没有用户级微代理",
"zh-TW": "您沒有使用者層級的微代理",
"ko-KR": "사용자 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på brukernivå",
"it": "Non hai microagenti a livello utente",
"pt": "Você não possui microagentes de nível de usuário",
"es": "No tienes microagentes a nivel de usuario",
"ar": "ليس لديك وكلاء دقيقون على مستوى المستخدم",
"fr": "Vous n'avez pas de microagents au niveau utilisateur",
"tr": "Kullanıcı düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Benutzerebene",
"uk": "У вас немає мікроагентів на рівні користувача"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": {
"en": "You do not have microagents",
"ja": "マイクロエージェントがありません",
"zh-CN": "您没有微代理",
"zh-TW": "您沒有微代理",
"ko-KR": "마이크로에이전트가 없습니다",
"no": "Du har ingen mikroagenter",
"it": "Non hai microagenti",
"pt": "Você não possui microagentes",
"es": "No tienes microagentes",
"ar": "ليس لديك وكلاء دقيقون",
"fr": "Vous n'avez pas de microagents",
"tr": "Mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten",
"uk": "У вас немає мікроагентів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": {
"en": "You do not have organization-level microagents",
"ja": "組織レベルのマイクロエージェントがありません",
"zh-CN": "您没有组织级微代理",
"zh-TW": "您沒有組織層級的微代理",
"ko-KR": "조직 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på organisasjonsnivå",
"it": "Non hai microagenti a livello organizzazione",
"pt": "Você não possui microagentes de nível organizacional",
"es": "No tienes microagentes a nivel de organización",
"ar": "ليس لديك وكلاء دقيقون على مستوى المؤسسة",
"fr": "Vous n'avez pas de microagents au niveau organisation",
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
"uk": "У вас немає мікроагентів на рівні організації"
},
"COMMON$SEARCH_REPOSITORIES": {
"en": "Search repositories",
"ja": "リポジトリを検索",
"zh-CN": "搜索仓库",
"zh-TW": "搜尋存儲庫",
"ko-KR": "저장소 검색",
"no": "Søk i repositories",
"it": "Cerca repository",
"pt": "Pesquisar repositórios",
"es": "Buscar repositorios",
"ar": "البحث في المستودعات",
"fr": "Rechercher des dépôts",
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
},
"COMMON$READY_FOR_REVIEW": {
"en": "Ready for review",
"ja": "レビューの準備ができました",
"zh-CN": "准备好审核",
"zh-TW": "已準備好審查",
"ko-KR": "검토 준비 완료",
"no": "Klar for gjennomgang",
"it": "Pronto per la revisione",
"pt": "Pronto para revisão",
"es": "Listo para revisión",
"ar": "جاهز للمراجعة",
"fr": "Prêt pour la relecture",
"tr": "İncelemeye hazır",
"de": "Bereit zur Überprüfung",
"uk": "Готово до перегляду"
},
"COMMON$COMPLETED": {
"en": "Completed",
"ja": "完了",
"zh-CN": "已完成",
"zh-TW": "已完成",
"ko-KR": "완료됨",
"no": "Fullført",
"it": "Completato",
"pt": "Concluído",
"es": "Completado",
"ar": "مكتمل",
"fr": "Terminé",
"tr": "Tamamlandı",
"de": "Abgeschlossen",
"uk": "Завершено"
},
"COMMON$COMPLETED_PARTIALLY": {
"en": "Completed partially",
"ja": "一部完了",
"zh-CN": "部分完成",
"zh-TW": "部分完成",
"ko-KR": "부분적으로 완료됨",
"no": "Delvis fullført",
"it": "Completato parzialmente",
"pt": "Concluído parcialmente",
"es": "Completado parcialmente",
"ar": "مكتمل جزئيًا",
"fr": "Partiellement terminé",
"tr": "Kısmen tamamlandı",
"de": "Teilweise abgeschlossen",
"uk": "Частково завершено"
},
"COMMON$STOPPED": {
"en": "Stopped",
"ja": "停止しました",
"zh-CN": "已停止",
"zh-TW": "已停止",
"ko-KR": "중지됨",
"no": "Stoppet",
"it": "Interrotto",
"pt": "Parado",
"es": "Detenido",
"ar": "متوقف",
"fr": "Arrêté",
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
},
"COMMON$WORKING_ON_IT": {
"en": "Working on it",
"ja": "作業中",
"zh-CN": "正在处理",
"zh-TW": "正在處理",
"ko-KR": "작업 중",
"no": "Jobber med det",
"it": "Ci sto lavorando",
"pt": "Trabalhando nisso",
"es": "Trabajando en ello",
"ar": "يتم العمل عليه",
"fr": "En cours",
"tr": "Üzerinde çalışılıyor",
"de": "Wird bearbeitet",
"uk": "В процесі виконання"
},
"MICROAGENT_MANAGEMENT$WE_ARE_WORKING_ON_IT": {
"en": "We're working on it! Once OpenHands is done investigating, you'll be able to review its pull request before merging your new microagent.",
"ja": "作業中です!OpenHandsの調査が完了すると、新しいマイクロエージェントをマージする前にプルリクエストを確認できます。",
"zh-CN": "我们正在处理!OpenHands 调查完成后,您将能够在合并新微代理之前审查其拉取请求。",
"zh-TW": "我們正在處理!OpenHands 調查完成後,您將能在合併新微代理前審查其拉取請求。",
"ko-KR": "작업 중입니다! OpenHands의 조사가 끝나면 새 마이크로에이전트를 병합하기 전에 풀 리퀘스트를 검토할 수 있습니다.",
"no": "Vi jobber med det! Når OpenHands er ferdig med å undersøke, kan du gjennomgå pull requesten før du slår sammen din nye mikroagent.",
"it": "Ci stiamo lavorando! Una volta che OpenHands avrà terminato l'analisi, potrai rivedere la pull request prima di unire il tuo nuovo microagent.",
"pt": "Estamos trabalhando nisso! Assim que o OpenHands terminar a investigação, você poderá revisar o pull request antes de mesclar seu novo microagente.",
"es": "¡Estamos trabajando en ello! Una vez que OpenHands termine de investigar, podrás revisar su pull request antes de fusionar tu nuevo microagente.",
"ar": "نحن نعمل على ذلك! بمجرد أن ينتهي OpenHands من التحقيق، ستتمكن من مراجعة طلب السحب قبل دمج وكيلك الدقيق الجديد.",
"fr": "Nous y travaillons ! Une fois qu'OpenHands aura terminé l'investigation, vous pourrez examiner sa pull request avant de fusionner votre nouveau microagent.",
"tr": "Üzerinde çalışıyoruz! OpenHands incelemeyi bitirdiğinde, yeni mikro ajanınızı birleştirmeden önce pull request'i gözden geçirebileceksiniz.",
"de": "Wir arbeiten daran! Sobald OpenHands die Untersuchung abgeschlossen hat, können Sie den Pull Request überprüfen, bevor Sie Ihren neuen Microagenten zusammenführen.",
"uk": "Ми працюємо над цим! Після завершення розслідування OpenHands ви зможете переглянути його pull request перед об'єднанням нового мікроагента."
},
"MICROAGENT_MANAGEMENT$YOUR_MICROAGENT_IS_READY": {
"en": "Your microagent is ready! Merge the PR in GitHub to start using it.",
"ja": "マイクロエージェントの準備ができました!GitHubでPRをマージして使い始めましょう。",
"zh-CN": "您的微代理已准备就绪!在 GitHub 上合并 PR 即可开始使用。",
"zh-TW": "您的微代理已準備就緒!在 GitHub 上合併 PR 即可開始使用。",
"ko-KR": "마이크로에이전트가 준비되었습니다! GitHub에서 PR을 병합하여 사용을 시작하세요.",
"no": "Din mikroagent er klar! Slå sammen PR-en i GitHub for å begynne å bruke den.",
"it": "Il tuo microagente è pronto! Unisci la PR su GitHub per iniziare a usarlo.",
"pt": "Seu microagente está pronto! Faça o merge do PR no GitHub para começar a usá-lo.",
"es": "¡Tu microagente está listo! Haz merge del PR en GitHub para empezar a usarlo.",
"ar": "وكيلك المصغر جاهز! ادمج طلب السحب في GitHub لبدء استخدامه.",
"fr": "Votre micro-agent est prêt ! Fusionnez la PR sur GitHub pour commencer à l'utiliser.",
"tr": "Mikro ajanınız hazır! Kullanmak için GitHub'da PR'ı birleştirin.",
"de": "Ihr Microagent ist bereit! Führen Sie den PR in GitHub zusammen, um ihn zu verwenden.",
"uk": "Ваш мікроагент готовий! Злийте PR у GitHub, щоб почати ним користуватися."
},
"COMMON$REVIEW_PR_IN": {
"en": "Review PR in",
"ja": "でPRをレビュー",
"zh-CN": "在中审查PR",
"zh-TW": "在中審查PR",
"ko-KR": "에서 PR 검토",
"no": "Se gjennom PR i",
"it": "Revisiona la PR su",
"pt": "Revisar PR em",
"es": "Revisar PR en",
"ar": "مراجعة PR في",
"fr": "Examiner la PR sur",
"tr": "PR'ı şurada gözden geçir:",
"de": "PR überprüfen in",
"uk": "Переглянути PR у"
},
"COMMON$EDIT_IN": {
"en": "Edit in",
"ja": "で編集",
"zh-CN": "在中编辑",
"zh-TW": "在中編輯",
"ko-KR": "에서 편집",
"no": "Rediger i",
"it": "Modifica su",
"pt": "Editar em",
"es": "Editar en",
"ar": "تعديل في",
"fr": "Modifier dans",
"tr": "Şurada düzenle:",
"de": "Bearbeiten in",
"uk": "Редагувати у"
},
"COMMON$LEARN": {
"en": "Learn",
"ja": "学ぶ",
"zh-CN": "学习",
"zh-TW": "學習",
"ko-KR": "학습",
"no": "Lær",
"it": "Impara",
"pt": "Aprender",
"es": "Aprender",
"ar": "تعلم",
"fr": "Apprendre",
"tr": "Öğren",
"de": "Lernen",
"uk": "Вчитися"
},
"COMMON$STARTING": {
"en": "Starting",
"ja": "開始中",
"zh-CN": "启动中",
"zh-TW": "啟動中",
"ko-KR": "시작 중",
"no": "Starter",
"it": "Avvio",
"pt": "Iniciando",
"es": "Iniciando",
"ar": "جارٍ البدء",
"fr": "Démarrage",
"tr": "Başlatılıyor",
"de": "Wird gestartet",
"uk": "Запуск"
},
"MICROAGENT_MANAGEMENT$ERROR": {
"en": "The system has encountered an error. Please try again later.",
"ja": "システムでエラーが発生しました。後でもう一度お試しください。",
"zh-CN": "系统遇到错误。请稍后再试。",
"zh-TW": "系統發生錯誤。請稍後再試。",
"ko-KR": "시스템에 오류가 발생했습니다. 나중에 다시 시도해 주세요.",
"no": "Systemet har oppdaget en feil. Prøv igjen senere.",
"it": "Il sistema ha riscontrato un errore. Riprova più tardi.",
"pt": "O sistema encontrou um erro. Por favor, tente novamente mais tarde.",
"es": "El sistema ha encontrado un error. Por favor, inténtalo de nuevo más tarde.",
"ar": "واجه النظام خطأ. يرجى المحاولة مرة أخرى لاحقًا.",
"fr": "Le système a rencontré une erreur. Veuillez réessayer plus tard.",
"tr": "Sistem bir hata ile karşılaştı. Lütfen daha sonra tekrar deneyin.",
"de": "Das System hat einen Fehler festgestellt. Bitte versuchen Sie es später erneut.",
"uk": "Система зіткнулася з помилкою. Будь ласка, спробуйте пізніше."
},
"MICROAGENT_MANAGEMENT$CONVERSATION_STOPPED": {
"en": "The conversation has been stopped.",
"ja": "会話が停止されました。",
"zh-CN": "对话已被停止。",
"zh-TW": "對話已被停止。",
"ko-KR": "대화가 중단되었습니다.",
"no": "Samtalen har blitt stoppet.",
"it": "La conversazione è stata interrotta.",
"pt": "A conversa foi interrompida.",
"es": "La conversación ha sido detenida.",
"ar": "تم إيقاف المحادثة.",
"fr": "La conversation a été arrêtée.",
"tr": "Konuşma durduruldu.",
"de": "Das Gespräch wurde gestoppt.",
"uk": "Розмову зупинено."
}
}
+30 -8
View File
@@ -1,11 +1,14 @@
import { redirect } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "#/components/features/microagent-management/microagent-management-sidebar";
import { Route } from "./+types/settings";
import { queryClient } from "#/query-client-config";
import { GetConfigResponse } from "#/api/open-hands.types";
import OpenHands from "#/api/open-hands";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "#/components/features/microagent-management/microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
@@ -28,12 +31,31 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
};
function MicroagentManagement() {
const { addMicroagentModalVisible } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
return (
<ConversationSubscriptionsProvider>
<EventHandler>
<MicroagentManagementContent />
</EventHandler>
</ConversationSubscriptionsProvider>
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={() => {
hideAddMicroagentModal();
}}
onCancel={() => {
hideAddMicroagentModal();
}}
/>
)}
</div>
);
}
@@ -1,46 +1,29 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
import { IMicroagentItem } from "#/types/microagent-management";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null as GitRepository | null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
selectedMicroagentItem: null as IMicroagentItem | null,
selectedRepository: null,
},
reducers: {
setSelectedMicroagent: (state, action) => {
state.selectedMicroagent = action.payload;
},
setAddMicroagentModalVisible: (state, action) => {
state.addMicroagentModalVisible = action.payload;
},
setSelectedRepository: (state, action) => {
state.selectedRepository = action.payload;
},
setPersonalRepositories: (state, action) => {
state.personalRepositories = action.payload;
},
setOrganizationRepositories: (state, action) => {
state.organizationRepositories = action.payload;
},
setRepositories: (state, action) => {
state.repositories = action.payload;
},
setSelectedMicroagentItem: (state, action) => {
state.selectedMicroagentItem = action.payload;
},
},
});
export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
setSelectedMicroagentItem,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;
-24
View File
@@ -20,27 +20,3 @@
.heading {
@apply text-[28px] leading-8 -tracking-[0.02em] font-bold text-content-2;
}
.loader {
background: #C9B974;
animation: l5 1s infinite linear alternate;
}
@keyframes l5 {
0% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: #C9B974;
}
33% {
box-shadow: 20px 0 #C9B974, -20px 0 rgba(201,185,116,0.1);
background: rgba(201,185,116,0.1);
}
66% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: rgba(201,185,116,0.1);
}
100% {
box-shadow: 20px 0 rgba(201,185,116,0.1), -20px 0 #C9B974;
background: #C9B974;
}
}
-1
View File
@@ -30,7 +30,6 @@ interface GitRepository {
stargazers_count?: number;
link_header?: string;
pushed_at?: string;
owner_type?: "user" | "organization";
}
interface GitHubCommit {
@@ -1,26 +0,0 @@
import { Conversation } from "#/api/open-hands.types";
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
inputs: string[];
tools: string[];
created_at: string;
git_provider: string;
path: string;
}
export interface IMicroagentItem {
microagent?: RepositoryMicroagent;
conversation?: Conversation;
}
export interface MicroagentFormData {
query: string;
triggers: string[];
selectedBranch: string;
}
-9
View File
@@ -28,12 +28,3 @@ export const JSON_VIEW_THEME = {
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};
export const DOCUMENTATION_URL = {
MICROAGENTS: {
MICROAGENTS_OVERVIEW:
"https://docs.all-hands.dev/usage/prompting/microagents-overview",
ORGANIZATION_AND_USER_MICROAGENTS:
"https://docs.all-hands.dev/usage/prompting/microagents-org",
},
};
-16
View File
@@ -26,19 +26,3 @@ export const formatTimeDelta = (date: Date) => {
if (months < 12) return `${months}mo`;
return `${years}y`;
};
/**
* Formats a date into a MM/DD/YYYY string format.
* @param date The date to format
* @returns A string in MM/DD/YYYY format
*
* @example
* formatDateMMDDYYYY(new Date("2025-05-30T00:15:08")); // "05/30/2025"
* formatDateMMDDYYYY(new Date("2024-12-25T10:30:00")); // "12/25/2024"
*/
export const formatDateMMDDYYYY = (date: Date) =>
date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
-105
View File
@@ -1,6 +1,5 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Provider } from "#/types/settings";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -103,107 +102,3 @@ export const formatTimestamp = (timestamp: string) =>
minute: "2-digit",
second: "2-digit",
});
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
switch (gitProvider) {
case "github":
return "https://github.com";
case "gitlab":
return "https://gitlab.com";
case "bitbucket":
return "https://bitbucket.org";
default:
return "";
}
};
/**
* Get the name of the git provider
* @param gitProvider The git provider
* @returns The name of the git provider
*/
export const getProviderName = (gitProvider: Provider) => {
if (gitProvider === "gitlab") return "GitLab";
if (gitProvider === "bitbucket") return "Bitbucket";
return "GitHub";
};
/**
* Get the name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The name of the PR
*/
export const getPR = (isGitLab: boolean) =>
isGitLab ? "merge request" : "pull request";
/**
* Get the short name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
/**
* Construct the pull request (merge request) URL for different providers
* @param prNumber The pull request number
* @param provider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @returns The pull request URL
*
* @example
* constructPullRequestUrl(123, "github", "owner/repo") // "https://github.com/owner/repo/pull/123"
* constructPullRequestUrl(456, "gitlab", "owner/repo") // "https://gitlab.com/owner/repo/-/merge_requests/456"
* constructPullRequestUrl(789, "bitbucket", "owner/repo") // "https://bitbucket.org/owner/repo/pull-requests/789"
*/
export const constructPullRequestUrl = (
prNumber: number,
provider: Provider,
repositoryName: string,
): string => {
const baseUrl = getGitProviderBaseUrl(provider);
switch (provider) {
case "github":
return `${baseUrl}/${repositoryName}/pull/${prNumber}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/merge_requests/${prNumber}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/pull-requests/${prNumber}`;
default:
return "";
}
};
/**
* Construct the microagent URL for different providers
* @param gitProvider The git provider
* @param repositoryName The repository name in format "owner/repo"
* @param microagentPath The path to the microagent in the repository
* @returns The URL to the microagent file in the Git provider
*
* @example
* constructMicroagentUrl("github", "owner/repo", ".openhands/microagents/tell-me-a-joke.md")
* // "https://github.com/owner/repo/blob/main/.openhands/microagents/tell-me-a-joke.md"
* constructMicroagentUrl("gitlab", "owner/repo", "microagents/git-helper.md")
* // "https://gitlab.com/owner/repo/-/blob/main/microagents/git-helper.md"
* constructMicroagentUrl("bitbucket", "owner/repo", ".openhands/microagents/docker-helper.md")
* // "https://bitbucket.org/owner/repo/src/main/.openhands/microagents/docker-helper.md"
*/
export const constructMicroagentUrl = (
gitProvider: Provider,
repositoryName: string,
microagentPath: string,
): string => {
const baseUrl = getGitProviderBaseUrl(gitProvider);
switch (gitProvider) {
case "github":
return `${baseUrl}/${repositoryName}/blob/main/${microagentPath}`;
case "gitlab":
return `${baseUrl}/${repositoryName}/-/blob/main/${microagentPath}`;
case "bitbucket":
return `${baseUrl}/${repositoryName}/src/main/${microagentPath}`;
default:
return "";
}
};
+94 -84
View File
@@ -17,9 +17,7 @@ from openhands.cli.settings import modify_llm_settings_basic
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
alias_setup_declined,
aliases_exist_in_shell_config,
mark_alias_setup_declined,
)
from openhands.cli.tui import (
UsageMetrics,
@@ -389,86 +387,106 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
Handles existing aliases by offering to keep or remove them.
Args:
config: OpenHands configuration
"""
print_formatted_text('')
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
print_formatted_text('')
# Show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML('<grey>This will add the following aliases to your shell profile:</grey>')
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
# Check if aliases already exist
if aliases_exist_in_shell_config():
print_formatted_text(
HTML(
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
# Mark that the user has declined alias setup
mark_alias_setup_declined()
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
)
return # Exit early since aliases already exist
else:
# No existing aliases, show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>This will add the following aliases to your shell profile:</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
)
)
print_formatted_text('')
@@ -565,23 +583,15 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
finalize_config(config)
# Check if we should show the alias setup flow
# Only show it if:
# 1. Aliases don't exist in the shell configuration
# 2. User hasn't previously declined alias setup
# 3. We're in an interactive environment (not during tests or CI)
should_show_alias_setup = (
not aliases_exist_in_shell_config()
and not alias_setup_declined()
and sys.stdin.isatty()
)
if should_show_alias_setup:
# Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
# Only show it if aliases don't exist in the shell configuration
# and we're in an interactive environment (not during tests or CI)
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
# Clear the terminal if we haven't shown a banner yet
if not banner_shown:
clear()
run_alias_setup_flow(config)
# Don't set banner_shown = True here, so the ASCII art banner will still be shown
banner_shown = True
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
-18
View File
@@ -277,21 +277,3 @@ def get_shell_config_path() -> Path:
"""Get the path to the shell configuration file."""
manager = ShellConfigManager()
return manager.get_shell_config_path()
def alias_setup_declined() -> bool:
"""Check if the user has previously declined alias setup.
Returns:
True if user has declined alias setup, False otherwise.
"""
marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
return marker_file.exists()
def mark_alias_setup_declined() -> None:
"""Mark that the user has declined alias setup."""
openhands_dir = Path.home() / '.openhands'
openhands_dir.mkdir(exist_ok=True)
marker_file = openhands_dir / '.cli_alias_setup_declined'
marker_file.touch()
-7
View File
@@ -42,13 +42,6 @@ def suppress_cli_warnings():
category=UserWarning,
)
# Suppress LiteLLM close_litellm_async_clients was never awaited warning
warnings.filterwarnings(
'ignore',
message="coroutine 'close_litellm_async_clients' was never awaited",
category=RuntimeWarning,
)
# Apply warning suppressions when module is imported
suppress_cli_warnings()
@@ -9,6 +9,7 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -20,7 +21,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class BitBucketService(BaseGitService, GitService):
class BitBucketService(BaseGitService, GitService, InstallationsService):
"""Default implementation of GitService for Bitbucket integration.
This is an extension point in OpenHands that allows applications to customize Bitbucket
@@ -185,7 +186,89 @@ class BitBucketService(BaseGitService, GitService):
return all_items[:max_items] # Trim to max_items if needed
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_installations(self) -> list[str]:
workspaces_url = f'{self.BASE_URL}/workspaces'
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
installations: list[str] = []
for workspace in workspaces:
installations.append(workspace['slug'])
return installations
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get paginated repositories for a specific workspace.
Args:
page: The page number to fetch
per_page: The number of repositories per page
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
Returns:
A list of Repository objects
"""
if not installation_id:
return []
# Convert installation_id to string for use as workspace_slug
workspace_slug = installation_id
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
# Map sort parameter to Bitbucket API compatible values
bitbucket_sort = sort
if sort == 'pushed':
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
elif sort == 'updated':
bitbucket_sort = '-updated_on'
elif sort == 'created':
bitbucket_sort = '-created_on'
elif sort == 'full_name':
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
else:
# Default to most recently updated first
bitbucket_sort = '-updated_on'
params = {
'pagelen': per_page,
'page': page,
'sort': bitbucket_sort,
}
response, headers = await self._make_request(workspace_repos_url, params)
# Extract repositories from the response
repos = response.get('values', [])
# Extract link header for pagination
next_link = response.get('next', '')
repositories = [
Repository(
id=repo.get('uuid', ''),
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
git_provider=ProviderType.BITBUCKET,
is_public=repo.get('is_private', True) is False,
stargazers_count=None, # Bitbucket doesn't have stars
pushed_at=repo.get('updated_on'),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('workspace', {}).get('is_private') is False
else OwnerType.USER
),
link_header=next_link,
)
for repo in repos
]
return repositories
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user using workspaces endpoint.
This method gets all repositories (both public and private) that the user has access to
@@ -15,6 +15,7 @@ from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
InstallationsService,
OwnerType,
ProviderType,
Repository,
@@ -28,7 +29,7 @@ from openhands.server.types import AppMode
from openhands.utils.import_utils import get_impl
class GitHubService(BaseGitService, GitService):
class GitHubService(BaseGitService, GitService, InstallationsService):
"""Default implementation of GitService for GitHub integration.
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
@@ -192,14 +193,47 @@ class GitHubService(BaseGitService, GitService):
ts = repo.get('pushed_at')
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
):
params = {'page': str(page), 'per_page': str(per_page)}
if installation_id:
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
response, headers = await self._make_request(url, params)
response = response.get('repositories', [])
else:
url = f'{self.BASE_URL}/user/repos'
params['sort'] = sort
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
return [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('full_name'), # type: ignore[arg-type]
stargazers_count=repo.get('stargazers_count'),
git_provider=ProviderType.GITHUB,
is_public=not repo.get('private', True),
owner_type=(
OwnerType.ORGANIZATION
if repo.get('owner', {}).get('type') == 'Organization'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitHub API
all_repos: list[dict] = []
if app_mode == AppMode.SAAS:
# Get all installation IDs and fetch repos for each one
installation_ids = await self.get_installation_ids()
installation_ids = await self.get_installations()
# Iterate through each installation ID
for installation_id in installation_ids:
@@ -246,11 +280,11 @@ class GitHubService(BaseGitService, GitService):
for repo in all_repos
]
async def get_installation_ids(self) -> list[int]:
async def get_installations(self) -> list[str]:
url = f'{self.BASE_URL}/user/installations'
response, _ = await self._make_request(url)
installations = response.get('installations', [])
return [i['id'] for i in installations]
return [str(i['id']) for i in installations]
async def search_repositories(
self, query: str, per_page: int, sort: str, order: str
@@ -226,7 +226,49 @@ class GitLabService(BaseGitService, GitService):
return repos
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
url = f'{self.BASE_URL}/projects'
order_by = {
'pushed': 'last_activity_at',
'updated': 'last_activity_at',
'created': 'created_at',
'full_name': 'name',
}.get(sort, 'last_activity_at')
params = {
'page': str(page),
'per_page': str(per_page),
'order_by': order_by,
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
'owned': True, # Boolean value without quotes
'membership': True, # Include projects user is a member of
}
response, headers = await self._make_request(url, params)
next_link: str = headers.get('Link', '')
repos = [
Repository(
id=str(repo.get('id')), # type: ignore[arg-type]
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=repo.get('visibility') == 'public',
owner_type=(
OwnerType.ORGANIZATION
if repo.get('namespace', {}).get('kind') == 'group'
else OwnerType.USER
),
link_header=next_link,
)
for repo in response
]
return repos
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
MAX_REPOS = 1000
PER_PAGE = 100 # Maximum allowed by GitLab API
all_repos: list[dict] = []
+49 -3
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, overload
from typing import Annotated, Any, Coroutine, Literal, cast, overload
from pydantic import (
BaseModel,
@@ -22,6 +22,7 @@ from openhands.integrations.service_types import (
AuthenticationError,
Branch,
GitService,
InstallationsService,
ProviderType,
Repository,
SuggestedTask,
@@ -160,16 +161,61 @@ class ProviderHandler:
service = self._get_service(provider)
return await service.get_latest_token()
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get github installations {e}')
return []
async def get_bitbucket_workspaces(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
try:
return await service.get_installations()
except Exception as e:
logger.warning(f'Failed to get bitbucket workspaces {e}')
return []
async def get_repositories(
self,
sort: str,
app_mode: AppMode,
selected_provider: ProviderType | None,
page: int | None,
per_page: int | None,
installation_id: str | None,
) -> list[Repository]:
"""
Get repositories from providers
"""
"""
Get repositories from providers
"""
if selected_provider:
if not page or not per_page:
logger.error('Failed to provider params for paginating repos')
return []
service = self._get_service(selected_provider)
try:
return await service.get_paginated_repos(
page, per_page, sort, installation_id
)
except Exception as e:
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
return []
all_repos: list[Repository] = []
for provider in self.provider_tokens:
try:
service = self._get_service(provider)
service_repos = await service.get_repositories(sort, app_mode)
service_repos = await service.get_all_repositories(sort, app_mode)
all_repos.extend(service_repos)
except Exception as e:
logger.warning(f'Error fetching repos from {provider}: {e}')
+15 -1
View File
@@ -200,6 +200,12 @@ class BaseGitService(ABC):
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
class InstallationsService(Protocol):
async def get_installations(self) -> list[str]:
"""Get installations for the service; repos live underneath these installations"""
...
class GitService(Protocol):
"""Protocol defining the interface for Git service providers"""
@@ -233,10 +239,18 @@ class GitService(Protocol):
"""Search for repositories"""
...
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
async def get_all_repositories(
self, sort: str, app_mode: AppMode
) -> list[Repository]:
"""Get repositories for the authenticated user"""
...
async def get_paginated_repos(
self, page: int, per_page: int, sort: str, installation_id: str | None
) -> list[Repository]:
"""Get a page of repositories for the authenticated user"""
...
async def get_suggested_tasks(self) -> list[SuggestedTask]:
"""Get suggested tasks for the authenticated user across all repositories"""
...
+1 -6
View File
@@ -72,10 +72,7 @@ from openhands.runtime.utils.bash import BashSession
from openhands.runtime.utils.files import insert_lines, read_lines
from openhands.runtime.utils.memory_monitor import MemoryMonitor
from openhands.runtime.utils.runtime_init import init_user_and_working_directory
from openhands.runtime.utils.system_stats import (
get_system_stats,
update_last_execution_time,
)
from openhands.runtime.utils.system_stats import get_system_stats
from openhands.utils.async_utils import call_sync_from_async, wait_all
if sys.platform == 'win32':
@@ -847,8 +844,6 @@ if __name__ == '__main__':
status_code=500,
detail=traceback.format_exc(),
)
finally:
update_last_execution_time()
@app.post('/update_mcp_server')
async def update_mcp_server(request: Request):
@@ -46,7 +46,6 @@ from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
from openhands.runtime.base import Runtime
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils.request import send_request
from openhands.runtime.utils.system_stats import update_last_execution_time
from openhands.utils.http_session import HttpSession
from openhands.utils.tenacity_stop import stop_if_should_exit
@@ -329,8 +328,6 @@ class ActionExecutionClient(Runtime):
raise AgentRuntimeTimeoutError(
f'Runtime failed to return execute_action before the requested timeout of {action.timeout}s'
)
finally:
update_last_execution_time()
return obs
def run(self, action: CmdRunAction) -> Observation:
+14 -100
View File
@@ -31,7 +31,6 @@ from openhands.runtime.utils.command import (
get_action_execution_server_startup_command,
)
from openhands.runtime.utils.log_streamer import LogStreamer
from openhands.runtime.utils.port_lock import PortLock, find_available_port_with_lock
from openhands.runtime.utils.runtime_build import build_runtime_image
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import add_shutdown_listener
@@ -105,11 +104,6 @@ class DockerRuntime(ActionExecutionClient):
self._vscode_port = -1
self._app_ports: list[int] = []
# Port locks to prevent race conditions
self._host_port_lock: PortLock | None = None
self._vscode_port_lock: PortLock | None = None
self._app_port_locks: list[PortLock] = []
if os.environ.get('DOCKER_HOST_ADDR'):
logger.info(
f'Using DOCKER_HOST_IP: {os.environ["DOCKER_HOST_ADDR"]} for local_runtime_url'
@@ -216,7 +210,6 @@ class DockerRuntime(ActionExecutionClient):
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
extra_build_args=self.config.sandbox.runtime_extra_build_args,
enable_browser=self.config.enable_browser,
)
@staticmethod
@@ -282,31 +275,17 @@ class DockerRuntime(ActionExecutionClient):
def init_container(self) -> None:
self.log('debug', 'Preparing to start container...')
self.set_runtime_status(RuntimeStatus.STARTING_RUNTIME)
# Allocate host port with locking to prevent race conditions
self._host_port, self._host_port_lock = self._find_available_port_with_lock(
EXECUTION_SERVER_PORT_RANGE
)
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
# Use the configured vscode_port if provided, otherwise find an available port
if self.config.sandbox.vscode_port:
self._vscode_port = self.config.sandbox.vscode_port
self._vscode_port_lock = None # No lock needed for configured port
else:
self._vscode_port, self._vscode_port_lock = (
self._find_available_port_with_lock(VSCODE_PORT_RANGE)
)
# Allocate app ports with locking
app_port_1, app_lock_1 = self._find_available_port_with_lock(APP_PORT_RANGE_1)
app_port_2, app_lock_2 = self._find_available_port_with_lock(APP_PORT_RANGE_2)
self._app_ports = [app_port_1, app_port_2]
self._app_port_locks = [
lock for lock in [app_lock_1, app_lock_2] if lock is not None
self._vscode_port = (
self.config.sandbox.vscode_port
or self._find_available_port(VSCODE_PORT_RANGE)
)
self._app_ports = [
self._find_available_port(APP_PORT_RANGE_1),
self._find_available_port(APP_PORT_RANGE_2),
]
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
use_host_network = self.config.sandbox.use_host_network
@@ -496,28 +475,6 @@ class DockerRuntime(ActionExecutionClient):
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
stop_all_containers(close_prefix)
self._release_port_locks()
def _release_port_locks(self) -> None:
"""Release all acquired port locks."""
if self._host_port_lock:
self._host_port_lock.release()
self._host_port_lock = None
logger.debug(f'Released host port lock for port {self._host_port}')
if self._vscode_port_lock:
self._vscode_port_lock.release()
self._vscode_port_lock = None
logger.debug(f'Released VSCode port lock for port {self._vscode_port}')
for i, lock in enumerate(self._app_port_locks):
if lock:
lock.release()
logger.debug(
f'Released app port lock for port {self._app_ports[i] if i < len(self._app_ports) else "unknown"}'
)
self._app_port_locks.clear()
def _is_port_in_use_docker(self, port: int) -> bool:
containers = self.docker_client.containers.list()
@@ -527,58 +484,15 @@ class DockerRuntime(ActionExecutionClient):
return True
return False
def _find_available_port_with_lock(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> tuple[int, PortLock | None]:
"""Find an available port with race condition protection.
This method uses file-based locking to prevent multiple workers
from allocating the same port simultaneously.
Args:
port_range: Tuple of (min_port, max_port)
max_attempts: Maximum number of attempts to find a port
Returns:
Tuple of (port_number, port_lock) where port_lock may be None if locking failed
"""
# Try to find and lock an available port
result = find_available_port_with_lock(
min_port=port_range[0],
max_port=port_range[1],
max_attempts=max_attempts,
bind_address='0.0.0.0',
lock_timeout=1.0,
)
if result is None:
# Fallback to original method if port locking fails
logger.warning(
f'Port locking failed for range {port_range}, falling back to original method'
)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port, None
return port, None
port, port_lock = result
# Additional check with Docker to ensure port is not in use
if self._is_port_in_use_docker(port):
port_lock.release()
# Try again with a different port
logger.debug(f'Port {port} is in use by Docker, trying again')
return self._find_available_port_with_lock(port_range, max_attempts - 1)
return port, port_lock
def _find_available_port(
self, port_range: tuple[int, int], max_attempts: int = 5
) -> int:
"""Find an available port (legacy method for backward compatibility)."""
port, _ = self._find_available_port_with_lock(port_range, max_attempts)
port = port_range[1]
for _ in range(max_attempts):
port = find_available_tcp_port(port_range[0], port_range[1])
if not self._is_port_in_use_docker(port):
return port
# If no port is found after max_attempts, return the last tried port
return port
@property
+1 -1
View File
@@ -40,7 +40,7 @@ Two configuration options are required to use the Kubernetes runtime:
2. **Runtime Container Image**: Specify the container image to use for the runtime environment
```toml
[sandbox]
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik"
runtime_container_image = "docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik"
```
#### Additional Kubernetes Options
+2 -10
View File
@@ -623,16 +623,8 @@ def _create_server(
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
)
app_ports = [
int(
os.getenv('WORK_PORT_1')
or os.getenv('APP_PORT_1')
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
),
int(
os.getenv('WORK_PORT_2')
or os.getenv('APP_PORT_2')
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
),
int(os.getenv('APP_PORT_1') or str(find_available_tcp_port(*APP_PORT_RANGE_1))),
int(os.getenv('APP_PORT_2') or str(find_available_tcp_port(*APP_PORT_RANGE_2))),
]
# Get user info
@@ -250,7 +250,6 @@ class RemoteRuntime(ActionExecutionClient):
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
enable_browser=self.config.enable_browser,
)
response = self._send_runtime_api_request(
-268
View File
@@ -1,268 +0,0 @@
"""File-based port locking system for preventing race conditions in port allocation."""
import os
import random
import socket
import tempfile
import time
from typing import Optional
from openhands.core.logger import openhands_logger as logger
# Import fcntl only on Unix systems
try:
import fcntl
HAS_FCNTL = True
except ImportError:
HAS_FCNTL = False
class PortLock:
"""File-based lock for a specific port to prevent race conditions."""
def __init__(self, port: int, lock_dir: Optional[str] = None):
self.port = port
self.lock_dir = lock_dir or os.path.join(
tempfile.gettempdir(), 'openhands_port_locks'
)
self.lock_file_path = os.path.join(self.lock_dir, f'port_{port}.lock')
self.lock_fd: Optional[int] = None
self._locked = False
# Ensure lock directory exists
os.makedirs(self.lock_dir, exist_ok=True)
def acquire(self, timeout: float = 1.0) -> bool:
"""Acquire the lock for this port.
Args:
timeout: Maximum time to wait for the lock
Returns:
True if lock was acquired, False otherwise
"""
if self._locked:
return True
try:
if HAS_FCNTL:
# Unix-style file locking with fcntl
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC
)
# Try to acquire exclusive lock with timeout
start_time = time.time()
while time.time() - start_time < timeout:
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except (OSError, IOError):
# Lock is held by another process, wait a bit
time.sleep(0.01)
# Timeout reached
if self.lock_fd:
os.close(self.lock_fd)
self.lock_fd = None
return False
else:
# Windows fallback: use atomic file creation
start_time = time.time()
while time.time() - start_time < timeout:
try:
# Try to create lock file exclusively
self.lock_fd = os.open(
self.lock_file_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY
)
self._locked = True
# Write port number to lock file for debugging
os.write(self.lock_fd, f'{self.port}\n'.encode())
os.fsync(self.lock_fd)
logger.debug(f'Acquired lock for port {self.port}')
return True
except OSError:
# Lock file already exists, wait a bit
time.sleep(0.01)
# Timeout reached
return False
except Exception as e:
logger.debug(f'Failed to acquire lock for port {self.port}: {e}')
if self.lock_fd:
try:
os.close(self.lock_fd)
except OSError:
pass
self.lock_fd = None
return False
def release(self) -> None:
"""Release the lock."""
if self.lock_fd is not None:
try:
if HAS_FCNTL:
# Unix: unlock and close
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
os.close(self.lock_fd)
# Remove lock file (both Unix and Windows)
try:
os.unlink(self.lock_file_path)
except FileNotFoundError:
pass
logger.debug(f'Released lock for port {self.port}')
except Exception as e:
logger.warning(f'Error releasing lock for port {self.port}: {e}')
finally:
self.lock_fd = None
self._locked = False
def __enter__(self) -> 'PortLock':
if not self.acquire():
raise OSError(f'Could not acquire lock for port {self.port}')
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
self.release()
@property
def is_locked(self) -> bool:
return self._locked
def find_available_port_with_lock(
min_port: int = 30000,
max_port: int = 39999,
max_attempts: int = 20,
bind_address: str = '0.0.0.0',
lock_timeout: float = 1.0,
) -> Optional[tuple[int, PortLock]]:
"""Find an available port and acquire a lock for it.
This function combines file-based locking with port availability checking
to prevent race conditions in multi-process scenarios.
Args:
min_port: Minimum port number to try
max_port: Maximum port number to try
max_attempts: Maximum number of ports to try
bind_address: Address to bind to when checking availability
lock_timeout: Timeout for acquiring port lock
Returns:
Tuple of (port, lock) if successful, None otherwise
"""
rng = random.SystemRandom()
# Try random ports first for better distribution
random_attempts = min(max_attempts // 2, 10)
for _ in range(random_attempts):
port = rng.randint(min_port, max_port)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available (maybe in TIME_WAIT state)
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
# If random attempts failed, try sequential search
remaining_attempts = max_attempts - random_attempts
start_port = rng.randint(min_port, max_port - remaining_attempts)
for i in range(remaining_attempts):
port = start_port + i
if port > max_port:
port = min_port + (port - max_port - 1)
# Try to acquire lock first
lock = PortLock(port)
if lock.acquire(timeout=lock_timeout):
# Check if port is actually available
if _check_port_available(port, bind_address):
logger.debug(f'Found and locked available port {port}')
return port, lock
else:
# Port is locked but not available
lock.release()
# Small delay to reduce contention
time.sleep(0.001)
logger.error(
f'Could not find and lock available port in range {min_port}-{max_port} after {max_attempts} attempts'
)
return None
def _check_port_available(port: int, bind_address: str = '0.0.0.0') -> bool:
"""Check if a port is available by trying to bind to it."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((bind_address, port))
sock.close()
return True
except OSError:
return False
def cleanup_stale_locks(max_age_seconds: int = 300) -> int:
"""Clean up stale lock files.
Args:
max_age_seconds: Maximum age of lock files before they're considered stale
Returns:
Number of lock files cleaned up
"""
lock_dir = os.path.join(tempfile.gettempdir(), 'openhands_port_locks')
if not os.path.exists(lock_dir):
return 0
cleaned = 0
current_time = time.time()
try:
for filename in os.listdir(lock_dir):
if filename.startswith('port_') and filename.endswith('.lock'):
lock_path = os.path.join(lock_dir, filename)
try:
# Check if lock file is old
stat = os.stat(lock_path)
if current_time - stat.st_mtime > max_age_seconds:
# Try to remove stale lock
os.unlink(lock_path)
cleaned += 1
logger.debug(f'Cleaned up stale lock file: {filename}')
except (OSError, FileNotFoundError):
# File might have been removed by another process
pass
except OSError:
# Directory might not exist or be accessible
pass
if cleaned > 0:
logger.info(f'Cleaned up {cleaned} stale port lock files')
return cleaned
+4 -26
View File
@@ -32,7 +32,6 @@ def _generate_dockerfile(
base_image: str,
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
extra_deps: str | None = None,
enable_browser: bool = True,
) -> str:
"""Generate the Dockerfile content for the runtime image based on the base image.
@@ -40,7 +39,6 @@ def _generate_dockerfile(
- base_image (str): The base image provided for the runtime image
- build_from (BuildFromImageType): The build method for the runtime image.
- extra_deps (str):
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: The resulting Dockerfile content
@@ -57,7 +55,6 @@ def _generate_dockerfile(
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
extra_deps=extra_deps if extra_deps is not None else '',
enable_browser=enable_browser,
)
return dockerfile_content
@@ -114,7 +111,6 @@ def build_runtime_image(
dry_run: bool = False,
force_rebuild: bool = False,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
"""Prepares the final docker build folder.
@@ -129,7 +125,6 @@ def build_runtime_image(
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
- extra_build_args (List[str]): Additional build arguments to pass to the builder
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
@@ -147,7 +142,6 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -160,7 +154,6 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -174,10 +167,9 @@ def build_runtime_image_in_folder(
force_rebuild: bool,
platform: str | None = None,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
@@ -196,7 +188,6 @@ def build_runtime_image_in_folder(
base_image,
build_from=BuildFromImageType.SCRATCH,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
if not dry_run:
_build_sandbox_image(
@@ -235,7 +226,7 @@ def build_runtime_image_in_folder(
else:
logger.debug(f'Build [{hash_image_name}] from scratch')
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
prep_build_folder(build_folder, base_image, build_from, extra_deps)
if not dry_run:
_build_sandbox_image(
build_folder,
@@ -260,7 +251,6 @@ def prep_build_folder(
base_image: str,
build_from: BuildFromImageType,
extra_deps: str | None,
enable_browser: bool = True,
) -> None:
# Copy the source code to directory. It will end up in build_folder/code
# If package is not found, build from source code
@@ -292,7 +282,6 @@ def prep_build_folder(
base_image,
build_from=build_from,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
dockerfile_path = Path(build_folder, 'Dockerfile')
with open(str(dockerfile_path), 'w') as f:
@@ -312,13 +301,10 @@ def truncate_hash(hash: str) -> str:
return ''.join(result)
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
def get_hash_for_lock_files(base_image: str) -> str:
openhands_source_dir = Path(openhands.__file__).parent
md5 = hashlib.md5()
md5.update(base_image.encode())
# Only include enable_browser in hash when it's False for backward compatibility
if not enable_browser:
md5.update(str(enable_browser).encode())
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
@@ -392,10 +378,6 @@ if __name__ == '__main__':
parser.add_argument('--build_folder', type=str, default=None)
parser.add_argument('--force_rebuild', action='store_true', default=False)
parser.add_argument('--platform', type=str, default=None)
parser.add_argument('--enable_browser', action='store_true', default=True)
parser.add_argument(
'--no_enable_browser', dest='enable_browser', action='store_false'
)
args = parser.parse_args()
if args.build_folder is not None:
@@ -427,7 +409,6 @@ if __name__ == '__main__':
dry_run=True,
force_rebuild=args.force_rebuild,
platform=args.platform,
enable_browser=args.enable_browser,
)
_runtime_image_repo, runtime_image_source_tag = (
@@ -463,9 +444,6 @@ if __name__ == '__main__':
logger.debug('Building image in a temporary folder')
docker_builder = DockerRuntimeBuilder(docker.from_env())
image_name = build_runtime_image(
args.base_image,
docker_builder,
platform=args.platform,
enable_browser=args.enable_browser,
args.base_image, docker_builder, platform=args.platform
)
logger.debug(f'\nBuilt image: {image_name}\n')
@@ -127,9 +127,7 @@ RUN \
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
# Update and install additional tools
# (There used to be an "apt-get update" here, hopefully we can skip it.)
{% if enable_browser %}
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
{% endif %}
# Set environment variables
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
# Set permissions
-19
View File
@@ -4,25 +4,6 @@ import time
import psutil
_start_time = time.time()
_last_execution_time = time.time()
def get_system_info() -> dict[str, object]:
current_time = time.time()
uptime = current_time - _start_time
idle_time = current_time - _last_execution_time
return {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
def update_last_execution_time():
global _last_execution_time
_last_execution_time = time.time()
def get_system_stats() -> dict[str, object]:
"""Get current system resource statistics.
@@ -27,4 +27,3 @@ class ConversationInfo:
url: str | None = None
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)
+54 -4
View File
@@ -38,9 +38,55 @@ from openhands.server.user_auth import (
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
@app.get('/github/installations', response_model=list[str])
async def get_user_github_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/bitbucket/installations', response_model=list[str])
async def get_user_bitbucket_installations(
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
):
if provider_tokens:
client = ProviderHandler(
provider_tokens=provider_tokens,
external_auth_token=access_token,
external_auth_id=user_id,
)
return await client.get_github_installations()
return JSONResponse(
content='Git provider token required. (such as GitHub).',
status_code=status.HTTP_401_UNAUTHORIZED,
)
@app.get('/repositories', response_model=list[Repository])
async def get_user_repositories(
sort: str = 'pushed',
selected_provider: ProviderType | None = None,
page: int | None = None,
per_page: int | None = None,
installation_id: str | None = None,
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
access_token: SecretStr | None = Depends(get_access_token),
user_id: str | None = Depends(get_user_id),
@@ -53,7 +99,14 @@ async def get_user_repositories(
)
try:
return await client.get_repositories(sort, server_config.app_mode)
return await client.get_repositories(
sort,
server_config.app_mode,
selected_provider,
page,
per_page,
installation_id,
)
except AuthenticationError as e:
logger.info(
@@ -254,7 +307,6 @@ class MicroagentResponse(BaseModel):
tools: list[str] = []
created_at: datetime
git_provider: ProviderType
path: str # Path to the microagent in the Git provider (e.g., ".openhands/microagents/tell-me-a-joke")
def _get_file_creation_time(repo_dir: Path, file_path: Path) -> datetime:
@@ -454,7 +506,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
@@ -478,7 +529,6 @@ def _process_microagents(
),
created_at=created_at,
git_provider=git_provider,
path=str(agent_file_path.relative_to(repo_dir)),
)
)
+24 -3
View File
@@ -1,6 +1,11 @@
from fastapi import FastAPI
import time
from openhands.runtime.utils.system_stats import get_system_info
from fastapi import FastAPI, Request
from openhands.runtime.utils.system_stats import get_system_stats
start_time = time.time()
last_execution_time = start_time
def add_health_endpoints(app: FastAPI):
@@ -14,4 +19,20 @@ def add_health_endpoints(app: FastAPI):
@app.get('/server_info')
async def get_server_info():
return get_system_info()
current_time = time.time()
uptime = current_time - start_time
idle_time = current_time - last_execution_time
response = {
'uptime': uptime,
'idle_time': idle_time,
'resources': get_system_stats(),
}
return response
@app.middleware('http')
async def update_last_execution_time(request: Request, call_next):
global last_execution_time
response = await call_next(request)
last_execution_time = time.time()
return response
@@ -424,7 +424,6 @@ async def _get_conversation_info(
num_connections=num_connections,
url=agent_loop_info.url if agent_loop_info else None,
session_api_key=getattr(agent_loop_info, 'session_api_key', None),
pr_number=conversation.pr_number,
)
except Exception as e:
logger.error(
@@ -73,9 +73,9 @@ class FileConversationStore(ConversationStore):
metadata_dir = self.get_conversation_metadata_dir()
try:
conversation_ids = [
Path(path).name
path.split('/')[-2]
for path in self.file_store.list(metadata_dir)
if not Path(path).name.startswith('.')
if not path.startswith(f'{metadata_dir}/.')
]
except FileNotFoundError:
return ConversationMetadataResultSet([])
+1 -1
View File
@@ -6,7 +6,7 @@ requires = [
[tool.poetry]
name = "openhands-ai"
version = "0.50.0"
version = "0.49.1"
description = "OpenHands: Code Less, Make More"
authors = [ "OpenHands" ]
license = "MIT"
+1 -1
View File
@@ -212,7 +212,7 @@ def _load_runtime(
runtime_startup_env_vars: dict[str, str] | None = None,
docker_runtime_kwargs: dict[str, str] | None = None,
override_mcp_config: MCPConfig | None = None,
enable_browser: bool = False,
enable_browser: bool = True,
) -> tuple[Runtime, OpenHandsConfig]:
sid = 'rt_' + str(random.randint(100000, 999999))
+1 -3
View File
@@ -38,9 +38,7 @@ def test_view_file(temp_dir, runtime_cls, run_as_openhands):
def test_view_directory(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
try:
# Create test file
test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt')
-1
View File
@@ -36,7 +36,6 @@ def test_browsergym_eval_env(runtime_cls, temp_dir):
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
browsergym_eval_env='browsergym/miniwob.choose-list',
force_rebuild_runtime=True,
enable_browser=True,
)
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,

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