mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5444193d9b | |||
| 16a508c15d |
+21
-100
@@ -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
@@ -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
|
||||
|
||||
@@ -51,7 +51,8 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
On your repository, label an issue with `openhands` or add a message starting with
|
||||
`@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a pull request if it determines that the issue has been successfully resolved.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: GitLab Integration
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
|
||||
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
|
||||
set up, it will allow OpenHands to work with your GitLab repository.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
@@ -25,33 +25,6 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
|
||||
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
|
||||
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
|
||||
|
||||
## Working on GitLab Issues and Merge Requests Using Openhands
|
||||
|
||||
<Note>
|
||||
This feature works for personal projects and is available for group projects with a
|
||||
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
|
||||
|
||||
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
|
||||
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
|
||||
</Note>
|
||||
|
||||
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a merge request if it determines that the issue has been successfully resolved.
|
||||
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
|
||||
|
||||
### Working with Merge Requests
|
||||
|
||||
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -129,159 +129,4 @@ describe("ActionSuggestions", () => {
|
||||
expect(createPRPrompt).toContain("meaningful branch name");
|
||||
expect(createPRPrompt).not.toContain("SAME branch name");
|
||||
});
|
||||
|
||||
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
|
||||
// Test case for GitHub repository
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-github",
|
||||
title: "GitHub Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "github",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
// Mock user having both GitHub and Bitbucket tokens
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "github-token",
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
expect(prButton).toBeInTheDocument();
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// The suggestion should mention GitHub, not Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitHub")
|
||||
);
|
||||
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use GitLab terminology when git_provider is gitlab", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-gitlab",
|
||||
title: "GitLab Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "gitlab",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "gitlab-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention GitLab and "merge request" instead of "pull request"
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitLab")
|
||||
);
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("merge request")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-bitbucket",
|
||||
title: "Bitbucket Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "bitbucket",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
+32
-69
@@ -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"),
|
||||
|
||||
-1862
File diff suppressed because it is too large
Load Diff
Generated
+24
-20
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,11 +19,8 @@ export function ActionSuggestions({
|
||||
const [hasPullRequest, setHasPullRequest] = React.useState(false);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
// Use the git_provider from the conversation, not the user's authenticated providers
|
||||
const currentGitProvider = conversation?.git_provider;
|
||||
const isGitLab = currentGitProvider === "gitlab";
|
||||
const isBitbucket = currentGitProvider === "bitbucket";
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isBitbucket = providers.includes("bitbucket");
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+3
-12
@@ -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>
|
||||
|
||||
-26
@@ -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>
|
||||
);
|
||||
}
|
||||
+3
-15
@@ -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 (
|
||||
|
||||
+20
-144
@@ -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>
|
||||
|
||||
-230
@@ -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>
|
||||
);
|
||||
}
|
||||
-44
@@ -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>
|
||||
);
|
||||
}
|
||||
-19
@@ -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>
|
||||
);
|
||||
}
|
||||
-44
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-42
@@ -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;
|
||||
}
|
||||
|
||||
+17
-127
@@ -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>
|
||||
);
|
||||
|
||||
+38
@@ -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>
|
||||
);
|
||||
}
|
||||
-22
@@ -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>
|
||||
);
|
||||
}
|
||||
-47
@@ -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>
|
||||
);
|
||||
}
|
||||
+49
@@ -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>
|
||||
);
|
||||
}
|
||||
+33
-113
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
-119
@@ -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>
|
||||
);
|
||||
}
|
||||
-74
@@ -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
-8
@@ -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>
|
||||
);
|
||||
|
||||
+6
-19
@@ -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>
|
||||
|
||||
+3
-63
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
-73
@@ -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>
|
||||
);
|
||||
}
|
||||
-60
@@ -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>
|
||||
);
|
||||
}
|
||||
-35
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -32,26 +32,32 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
|
||||
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:underline mr-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
{isEditing
|
||||
? t(I18nKey.SETTINGS$MCP_CANCEL)
|
||||
: t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<MCPJsonEditor
|
||||
mcpConfig={mcpConfig}
|
||||
onChange={handleConfigChange}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
<MCPJsonEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MCPJsonEditorProps {
|
||||
mcpConfig?: MCPConfig;
|
||||
onChange: (config: MCPConfig) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MCPJsonEditor({
|
||||
mcpConfig,
|
||||
onChange,
|
||||
onCancel,
|
||||
}: MCPJsonEditorProps) {
|
||||
export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [configText, setConfigText] = useState(() =>
|
||||
mcpConfig
|
||||
@@ -71,31 +65,11 @@ export function MCPJsonEditor({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<Trans
|
||||
i18nKey={I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="mb-2 text-sm text-gray-400">
|
||||
{t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)}
|
||||
</div>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full h-64 resize-y p-2 rounded-sm text-sm font-mono",
|
||||
"bg-tertiary border border-[#717888]",
|
||||
"placeholder:italic placeholder:text-tertiary-alt",
|
||||
"focus:outline-none focus:ring-1 focus:ring-primary",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
className="w-full h-64 p-2 text-sm font-mono bg-base-tertiary rounded-md focus:border-blue-500 focus:outline-hidden"
|
||||
value={configText}
|
||||
onChange={handleTextChange}
|
||||
spellCheck="false"
|
||||
@@ -113,12 +87,9 @@ export function MCPJsonEditor({
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<BrandButton type="button" variant="secondary" onClick={onCancel}>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<BrandButton type="button" variant="primary" onClick={handleSave}>
|
||||
{t(I18nKey.SETTINGS$MCP_CONFIRM_CHANGES)}
|
||||
{t(I18nKey.SETTINGS$MCP_APPLY_CHANGES)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ function Terminal() {
|
||||
|
||||
const ref = useTerminal({
|
||||
commands,
|
||||
readOnly: true, // Make terminal read-only
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -16,10 +16,12 @@ import { parseTerminalOutput } from "#/utils/parse-terminal-output";
|
||||
|
||||
interface UseTerminalConfig {
|
||||
commands: Command[];
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
|
||||
commands: [],
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
const renderCommand = (command: Command, terminal: Terminal) => {
|
||||
@@ -36,6 +38,7 @@ const persistentLastCommandIndex = { current: 0 };
|
||||
|
||||
export const useTerminal = ({
|
||||
commands,
|
||||
readOnly = false,
|
||||
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
|
||||
const { send } = useWsClient();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
@@ -44,7 +47,7 @@ export const useTerminal = ({
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
|
||||
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
|
||||
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState) || readOnly;
|
||||
|
||||
const createTerminal = () =>
|
||||
new Terminal({
|
||||
|
||||
@@ -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",
|
||||
@@ -51,7 +50,8 @@ export enum I18nKey {
|
||||
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
|
||||
SETTINGS$MCP_CONFIGURATION = "SETTINGS$MCP_CONFIGURATION",
|
||||
SETTINGS$MCP_EDIT_CONFIGURATION = "SETTINGS$MCP_EDIT_CONFIGURATION",
|
||||
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_CHANGES",
|
||||
SETTINGS$MCP_CANCEL = "SETTINGS$MCP_CANCEL",
|
||||
SETTINGS$MCP_APPLY_CHANGES = "SETTINGS$MCP_APPLY_CHANGES",
|
||||
SETTINGS$MCP_CONFIG_DESCRIPTION = "SETTINGS$MCP_CONFIG_DESCRIPTION",
|
||||
SETTINGS$MCP_CONFIG_ERROR = "SETTINGS$MCP_CONFIG_ERROR",
|
||||
SETTINGS$MCP_CONFIG_EXAMPLE = "SETTINGS$MCP_CONFIG_EXAMPLE",
|
||||
@@ -579,6 +579,7 @@ export enum I18nKey {
|
||||
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
|
||||
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",
|
||||
@@ -709,21 +710,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",
|
||||
}
|
||||
|
||||
@@ -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": "マイクロエージェントの更新を表示",
|
||||
@@ -815,37 +799,53 @@
|
||||
"de": "Konfiguration bearbeiten",
|
||||
"uk": "Редагувати налаштування"
|
||||
},
|
||||
"SETTINGS$MCP_CONFIRM_CHANGES": {
|
||||
"en": "Confirm Changes",
|
||||
"ja": "変更を確定",
|
||||
"zh-CN": "确认更改",
|
||||
"zh-TW": "確認變更",
|
||||
"ko-KR": "변경 사항 확인",
|
||||
"no": "Bekreft endringer",
|
||||
"it": "Conferma modifiche",
|
||||
"pt": "Confirmar alterações",
|
||||
"es": "Confirmar cambios",
|
||||
"ar": "تأكيد التغييرات",
|
||||
"fr": "Confirmer les modifications",
|
||||
"tr": "Değişiklikleri Onayla",
|
||||
"de": "Änderungen bestätigen",
|
||||
"uk": "Підтвердити зміни"
|
||||
"SETTINGS$MCP_CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"ar": "إلغاء",
|
||||
"fr": "Annuler",
|
||||
"tr": "İptal",
|
||||
"de": "Abbrechen",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"SETTINGS$MCP_APPLY_CHANGES": {
|
||||
"en": "Apply Changes",
|
||||
"ja": "変更を適用",
|
||||
"zh-CN": "应用更改",
|
||||
"zh-TW": "應用更改",
|
||||
"ko-KR": "변경 사항 적용",
|
||||
"no": "Bruk endringer",
|
||||
"it": "Applica modifiche",
|
||||
"pt": "Aplicar alterações",
|
||||
"es": "Aplicar cambios",
|
||||
"ar": "تطبيق التغييرات",
|
||||
"fr": "Appliquer les modifications",
|
||||
"tr": "Değişiklikleri Uygula",
|
||||
"de": "Änderungen anwenden",
|
||||
"uk": "Застосувати зміни"
|
||||
},
|
||||
"SETTINGS$MCP_CONFIG_DESCRIPTION": {
|
||||
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays. For full configuration details and integration examples, see the <a>documentation</a>.",
|
||||
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。詳細な設定と統合の例については、<a>ドキュメント</a>を参照してください。",
|
||||
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。有关完整的配置详情和集成示例,请参阅<a>文档</a>。",
|
||||
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。有關完整配置詳情與整合範例,請參閱<a>文件</a>。",
|
||||
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다. 전체 구성 세부 정보와 통합 예시는 <a>문서</a>를 참조하세요.",
|
||||
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser. For detaljer om konfigurasjon og integrasjon, se <a>dokumentasjonen</a>.",
|
||||
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers. Per i dettagli completi sulla configurazione e gli esempi di integrazione, vedi la <a>documentazione</a>.",
|
||||
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers. Para detalhes completos de configuração e exemplos de integração, veja a <a>documentação</a>.",
|
||||
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers. Para ver detalles completos de configuración y ejemplos de integración, consulte la <a>documentación</a>.",
|
||||
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers. للحصول على تفاصيل التكوين الكاملة وأمثلة التكامل، راجع <a>التوثيق</a>.",
|
||||
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers. Pour plus de détails sur la configuration et des exemples d'intégration, voir la <a>documentation</a>.",
|
||||
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir. Tam yapılandırma ayrıntıları ve entegrasyon örnekleri için <a>belgeler</a>'e bakın.",
|
||||
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten. Weitere Konfigurationsdetails und Integrationsbeispiele finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers. Повну інформацію про конфігурацію та приклади інтеграції дивіться в <a>документації</a>."
|
||||
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays.",
|
||||
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。",
|
||||
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。",
|
||||
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。",
|
||||
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다.",
|
||||
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser.",
|
||||
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers.",
|
||||
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers.",
|
||||
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers.",
|
||||
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers.",
|
||||
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers.",
|
||||
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir.",
|
||||
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten.",
|
||||
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers."
|
||||
},
|
||||
"SETTINGS$MCP_CONFIG_ERROR": {
|
||||
"en": "Error:",
|
||||
@@ -9263,6 +9263,22 @@
|
||||
"de": "oder siehe",
|
||||
"uk": "або перегляньте"
|
||||
},
|
||||
"COMMON$DOCUMENTATION": {
|
||||
"en": "documentation",
|
||||
"ja": "ドキュメント",
|
||||
"zh-CN": "文档",
|
||||
"zh-TW": "文件",
|
||||
"ko-KR": "문서",
|
||||
"no": "dokumentasjon",
|
||||
"it": "documentazione",
|
||||
"pt": "documentação",
|
||||
"es": "documentación",
|
||||
"ar": "التوثيق",
|
||||
"fr": "documentation",
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation",
|
||||
"uk": "документація"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
|
||||
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
@@ -11213,7 +11229,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 +11358,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": "Розмову зупинено."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-1
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
+96
-100
@@ -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,
|
||||
@@ -233,26 +231,12 @@ async def run_session(
|
||||
return
|
||||
|
||||
confirmation_status = await read_confirmation_input(config)
|
||||
if confirmation_status in ('yes', 'always'):
|
||||
if confirmation_status == 'yes' or confirmation_status == 'always':
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
elif confirmation_status == 'edit':
|
||||
# Tell the agent the proposed action was rejected
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
# Notify the user
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<skyblue>Okay, please tell me what I should do instead.</skyblue>'
|
||||
)
|
||||
)
|
||||
# Solicit replacement isntructions
|
||||
await prompt_for_next_task(AgentState.AWAITING_USER_INPUT)
|
||||
else: # 'no' or fallback
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
@@ -389,86 +373,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 +569,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
+25
-11
@@ -589,20 +589,34 @@ async def read_prompt_input(
|
||||
|
||||
async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
try:
|
||||
choices = [
|
||||
'Yes, proceed',
|
||||
'No, skip this action',
|
||||
"Always proceed (don't ask again)",
|
||||
'Let me provide different instructions',
|
||||
]
|
||||
prompt_session = create_prompt_session(config)
|
||||
|
||||
# keep the outer coroutine responsive by using asyncio.to_thread which puts the blocking call app.run() of cli_confirm() in a separate thread
|
||||
index = await asyncio.to_thread(
|
||||
cli_confirm, config, 'Choose an option:', choices
|
||||
)
|
||||
while True:
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
confirmation: str = await prompt_session.prompt_async(
|
||||
HTML('<gold>Proceed with action? (y)es/(n)o/(a)lways > </gold>'),
|
||||
)
|
||||
|
||||
return {0: 'yes', 1: 'no', 2: 'always', 3: 'edit'}.get(index, 'no')
|
||||
confirmation = (
|
||||
'' if confirmation is None else confirmation.strip().lower()
|
||||
)
|
||||
|
||||
if confirmation in ['y', 'yes']:
|
||||
return 'yes'
|
||||
elif confirmation in ['n', 'no']:
|
||||
return 'no'
|
||||
elif confirmation in ['a', 'always']:
|
||||
return 'always'
|
||||
else:
|
||||
# Display error message for invalid input
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansired>Invalid input. Please enter (y)es, (n)o, or (a)lways.</ansired>'
|
||||
)
|
||||
)
|
||||
# Continue the loop to re-prompt
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return 'no'
|
||||
|
||||
|
||||
@@ -219,26 +219,10 @@ class EventStream(EventStore):
|
||||
def update_secrets(self, secrets: dict[str, str]) -> None:
|
||||
self.secrets.update(secrets)
|
||||
|
||||
def _replace_secrets(
|
||||
self, data: dict[str, Any], is_top_level: bool = True
|
||||
) -> dict[str, Any]:
|
||||
# Fields that should not have secrets replaced (only at top level - system metadata)
|
||||
TOP_LEVEL_PROTECTED_FIELDS = {
|
||||
'timestamp',
|
||||
'id',
|
||||
'source',
|
||||
'cause',
|
||||
'action',
|
||||
'observation',
|
||||
'message',
|
||||
}
|
||||
|
||||
def _replace_secrets(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in data:
|
||||
if is_top_level and key in TOP_LEVEL_PROTECTED_FIELDS:
|
||||
# Skip secret replacement for protected system fields at top level only
|
||||
continue
|
||||
elif isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key], is_top_level=False)
|
||||
if isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key])
|
||||
elif isinstance(data[key], str):
|
||||
for secret in self.secrets.values():
|
||||
data[key] = data[key].replace(secret, '<secret_hidden>')
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context
|
||||
1. Address the comment. Use the GitHub API to read issue title, body, and comments if you need more context
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the $GITHUB_TOKEN and Github API
|
||||
1. Read the issue body and comments using the Github API
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the PR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the PR Context
|
||||
Use the $GITHUB_TOKEN and GitHub API to:
|
||||
Use the GitHub API to:
|
||||
1. Retrieve the diff against main to understand the changes
|
||||
2. Fetch the PR body and the linked issue for context
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the $GITLAB_TOKEN and GitLab API to read issue title, body, and comments if you need more context
|
||||
1. Address the comment. Use the GitLab API to read issue title, body, and comments if you need more context
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the $GITLAB_TOKEN and GitLab API
|
||||
1. Read the issue body and comments using the GitLab API
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the MR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the MR Context
|
||||
Use the $GITLAB_TOKEN and GitLab API to:
|
||||
Use the GitLab API to:
|
||||
1. Retrieve the diff against main to understand the changes
|
||||
2. Fetch the MR body and the linked issue for context
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'gemini-2.5-pro',
|
||||
'gpt-4.1',
|
||||
'kimi-k2-0711-preview',
|
||||
'kimi-k2-instruct',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
@@ -811,8 +810,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message.function_calling_enabled = self.is_function_calling_active()
|
||||
if 'deepseek' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
if 'kimi-k2-instruct' in self.config.model and 'groq' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,7 +4,8 @@ from typing import Callable
|
||||
|
||||
@dataclass
|
||||
class CommandResult:
|
||||
"""Represents the result of a shell command execution.
|
||||
"""
|
||||
Represents the result of a shell command execution.
|
||||
|
||||
Attributes:
|
||||
content (str): The output content of the command.
|
||||
@@ -16,7 +17,9 @@ class CommandResult:
|
||||
|
||||
|
||||
class GitHandler:
|
||||
"""A handler for executing Git-related operations via shell commands."""
|
||||
"""
|
||||
A handler for executing Git-related operations via shell commands.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -26,7 +29,8 @@ class GitHandler:
|
||||
self.cwd: str | None = None
|
||||
|
||||
def set_cwd(self, cwd: str) -> None:
|
||||
"""Sets the current working directory for Git operations.
|
||||
"""
|
||||
Sets the current working directory for Git operations.
|
||||
|
||||
Args:
|
||||
cwd (str): The directory path.
|
||||
@@ -34,7 +38,8 @@ class GitHandler:
|
||||
self.cwd = cwd
|
||||
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""Checks if the current directory is a Git repository.
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
Returns:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
@@ -44,7 +49,8 @@ class GitHandler:
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""Retrieves the current content of a given file.
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
@@ -56,7 +62,8 @@ class GitHandler:
|
||||
return output.content
|
||||
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""Verifies whether a specific Git reference exists.
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
Args:
|
||||
ref (str): The Git reference to check.
|
||||
@@ -68,71 +75,10 @@ class GitHandler:
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
def _is_ahead_of_remote_branch(self, remote_branch: str) -> bool:
|
||||
"""Checks if the current branch is ahead of the specified remote branch.
|
||||
|
||||
Args:
|
||||
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
|
||||
|
||||
Returns:
|
||||
bool: True if current branch is ahead, False otherwise.
|
||||
"""
|
||||
cmd = f'git --no-pager rev-list --count {remote_branch}..HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
return int(output.content.strip()) > 0
|
||||
|
||||
def _includes_merged_main_commits(self, remote_branch: str, default_branch: str) -> bool:
|
||||
"""Checks if the local branch includes commits that were merged from the default branch.
|
||||
|
||||
Since the remote branch was last updated.
|
||||
|
||||
Args:
|
||||
remote_branch (str): The remote branch reference (e.g., 'origin/feature-branch').
|
||||
default_branch (str): The default branch name (e.g., 'main').
|
||||
|
||||
Returns:
|
||||
bool: True if merged main commits are included in the diff.
|
||||
"""
|
||||
# Get commits that are in HEAD but not in remote_branch
|
||||
cmd = f'git --no-pager log --oneline {remote_branch}..HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
|
||||
local_commits = output.content.strip().splitlines()
|
||||
if not local_commits:
|
||||
return False
|
||||
|
||||
# Get commits that are in origin/default_branch but not in remote_branch
|
||||
origin_default = f'origin/{default_branch}'
|
||||
if not self._verify_ref_exists(origin_default):
|
||||
return False
|
||||
|
||||
cmd = f'git --no-pager log --oneline {remote_branch}..{origin_default}'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code != 0:
|
||||
return False
|
||||
|
||||
main_commits = output.content.strip().splitlines()
|
||||
if not main_commits:
|
||||
return False
|
||||
|
||||
# Extract commit hashes from both lists
|
||||
local_hashes = {line.split()[0] for line in local_commits if line.strip()}
|
||||
main_hashes = {line.split()[0] for line in main_commits if line.strip()}
|
||||
|
||||
# If there's significant overlap, we likely have merged main commits
|
||||
overlap = local_hashes.intersection(main_hashes)
|
||||
return len(overlap) >= min(2, len(main_hashes) // 2)
|
||||
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""Determines a valid Git reference for comparison using a hybrid approach.
|
||||
|
||||
- Uses origin/current_branch when it's the best representation of push status
|
||||
- Falls back to merge-base when origin/current_branch includes merged main commits
|
||||
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
@@ -144,19 +90,8 @@ class GitHandler:
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
# Hybrid logic: check if origin/current_branch exists and causes pollution
|
||||
if self._verify_ref_exists(ref_current_branch):
|
||||
# If we're ahead of remote and it includes merged main commits, use merge-base instead
|
||||
if (self._is_ahead_of_remote_branch(ref_current_branch) and
|
||||
self._includes_merged_main_commits(ref_current_branch, default_branch)):
|
||||
# Try merge-base first to avoid pollution
|
||||
if self._verify_ref_exists(ref_non_default_branch):
|
||||
return ref_non_default_branch
|
||||
# Otherwise use origin/current_branch for normal push workflow
|
||||
return ref_current_branch
|
||||
|
||||
# Fallback to original logic
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
@@ -168,7 +103,8 @@ class GitHandler:
|
||||
return None
|
||||
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""Retrieves the content of a file from a valid Git reference.
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
Args:
|
||||
file_path (str): The file path in the repository.
|
||||
@@ -185,7 +121,8 @@ class GitHandler:
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
def _get_default_branch(self) -> str:
|
||||
"""Retrieves the primary Git branch name of the repository.
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
Returns:
|
||||
str: The name of the primary branch.
|
||||
@@ -195,7 +132,8 @@ class GitHandler:
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""Retrieves the currently selected Git branch.
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
Returns:
|
||||
str: The name of the current branch.
|
||||
@@ -205,7 +143,8 @@ class GitHandler:
|
||||
return output.content.strip()
|
||||
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""Retrieves a list of changed files compared to a valid Git reference.
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
@@ -223,7 +162,8 @@ class GitHandler:
|
||||
return output.content.splitlines()
|
||||
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
@@ -238,7 +178,8 @@ class GitHandler:
|
||||
)
|
||||
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""Retrieves the list of changed files in the Git repository.
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
|
||||
@@ -254,7 +195,8 @@ class GitHandler:
|
||||
return result
|
||||
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""Retrieves the original and modified content of a file in the repository.
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file.
|
||||
@@ -272,7 +214,8 @@ class GitHandler:
|
||||
|
||||
|
||||
def parse_git_changes(changes_list: list[str]) -> list[dict[str, str]]:
|
||||
"""Parses the list of changed files and extracts their statuses and paths.
|
||||
"""
|
||||
Parses the list of changed files and extracts their statuses and paths.
|
||||
|
||||
Args:
|
||||
changes_list (list[str]): List of changed file entries.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -254,7 +254,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 +453,6 @@ def _process_microagents(
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -478,7 +476,6 @@ def _process_microagents(
|
||||
),
|
||||
created_at=created_at,
|
||||
git_provider=git_provider,
|
||||
path=str(agent_file_path.relative_to(repo_dir)),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user