Compare commits

..

12 Commits

Author SHA1 Message Date
openhands 497fd4a02c Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 04:04:08 +00:00
openhands 7fac7d6dd0 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 03:03:01 +00:00
openhands 4155b8f801 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 02:39:00 +00:00
openhands e712b013f9 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-12 02:17:13 +00:00
OpenHands Bot 29b137e9b1 🤖 Auto-fix Python linting issues 2025-05-04 01:07:31 +00:00
Engel Nyst 1292f0c2ea Merge branch 'main' into delegates-cleanup 2025-05-04 03:06:23 +02:00
openhands da8c946078 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-04 00:44:28 +00:00
openhands 43cef1f969 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-04 00:14:47 +00:00
openhands ab661b485b Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 23:39:14 +00:00
openhands 9632914bf0 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 23:10:19 +00:00
openhands 3db780ef93 Fix pr #8247: Simplify delegate agents prompt and finish 2025-05-03 21:40:15 +00:00
Engel Nyst 43c16516e8 standardize delegate agents prompt and finish 2025-05-03 23:05:34 +02:00
203 changed files with 6663 additions and 3597 deletions
+1 -5
View File
@@ -34,10 +34,6 @@ on:
type: string
default: ""
description: "Custom sandbox env"
runner:
required: false
type: string
default: "ubuntu-latest"
secrets:
LLM_MODEL:
required: false
@@ -83,7 +79,7 @@ jobs:
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: "${{ inputs.runner }}"
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
-73
View File
@@ -1,73 +0,0 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# 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 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
if [ $? -ne 0 ]; then
echo "Frontend build failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run tests
echo "Running npm test..."
npm test
if [ $? -ne 0 ]; then
echo "Frontend tests failed. Please fix the failing tests before committing."
EXIT_CODE=1
fi
# Return to the original directory
cd ..
if [ $EXIT_CODE -eq 0 ]; then
echo "Frontend checks passed!"
fi
else
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
# This makes our hook additive rather than replacing existing hooks
if [ -f ".git/hooks/pre-commit.local" ]; then
echo "Running existing pre-commit hooks..."
bash .git/hooks/pre-commit.local
if [ $? -ne 0 ]; then
echo "Existing pre-commit hooks failed."
EXIT_CODE=1
fi
fi
if [ $EXIT_CODE -eq 0 ]; then
echo "All pre-commit checks passed!"
else
echo "Some pre-commit checks failed. Please fix the issues before committing."
fi
exit $EXIT_CODE
-7
View File
@@ -2,11 +2,4 @@
echo "Setting up the environment..."
# Install pre-commit package
python -m pip install pre-commit
# Install pre-commit hooks if .git directory exists
if [ -d ".git" ]; then
echo "Installing pre-commit hooks..."
pre-commit install
fi
+1 -1
View File
@@ -121,7 +121,7 @@ These Slack and Discord etiquette guidelines are designed to foster an inclusive
- Use threads for specific discussions to keep channels organized and easier to follow.
- Tag others only when their input is critical or urgent, and use @here, @channel or @everyone sparingly to minimize disruptions.
- Be patient, as open-source contributors and maintainers often have other commitments and may need time to respond.
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://openhands-ai.slack.com/archives/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions, [discord - #general](https://discord.com/channels/1222935860639563850/1222935861386018885)).
- Post questions or discussions in the most relevant channel (e.g., for [slack - #general](https://app.slack.com/client/T06P212QSEA/C06P5NCGSFP) for general topics, [slack - #questions](https://openhands-ai.slack.com/archives/C06U8UTKSAD) for queries/questions, [discord - #general](https://discord.com/channels/1222935860639563850/1222935861386018885)).
- When asking for help or raising issues, include necessary details like links, screenshots, or clear explanations to provide context.
- Keep discussions in public channels whenever possible to allow others to benefit from the conversation, unless the matter is sensitive or private.
- Always adhere to [our standards](https://github.com/All-Hands-AI/OpenHands/blob/main/CODE_OF_CONDUCT.md#our-standards) to ensure a welcoming and collaborative environment.
+2 -9
View File
@@ -11,7 +11,7 @@
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
<br/>
@@ -99,19 +99,12 @@ check out our [documentation](https://docs.all-hands.dev/modules/usage/getting-s
There you'll find resources on how to use different LLM providers,
troubleshooting resources, and advanced configuration options.
### Custom Scripts
OpenHands supports custom scripts that run at different points in the runtime lifecycle:
- **setup.sh**: Place this script in the `.openhands` directory of your repository to run custom setup commands when the runtime initializes.
- **pre-commit.sh**: Place this script in the `.openhands` directory to add a custom git pre-commit hook that runs before each commit. This can be used to enforce code quality standards, run tests, or perform other checks before allowing commits.
## 🤝 How to Join the Community
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A) - Here we talk about research, architecture, and future development.
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw) - Here we talk about research, architecture, and future development.
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
-17
View File
@@ -1,17 +0,0 @@
# MCP Server Configuration
# This configuration file adds the MCP server to the OpenHands configuration
# Include the MCP server in the configuration
[mcp]
# List of MCP SSE servers
sse_servers = [
{
# The URL of the MCP server
url = "http://localhost:12000/mcp",
# Optional API key for authentication (not required for local development)
api_key = ""
}
]
# List of MCP stdio servers (these will be started by the runtime)
stdio_servers = []
-4
View File
@@ -316,10 +316,6 @@ llm_config = 'gpt3'
# Additional Docker runtime kwargs
#docker_runtime_kwargs = {}
# Specific port to use for VSCode. If not set, a random port will be chosen.
# Useful when deploying OpenHands in a remote machine where you need to expose a specific port.
#vscode_port = 41234
#################################### Security ###################################
# Configuration for security features
##############################################################################
+1
View File
@@ -70,6 +70,7 @@ ENV VIRTUAL_ENV=/app/.venv \
PYTHONPATH='/app'
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
RUN playwright install --with-deps chromium
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
+8
View File
@@ -26,6 +26,10 @@ fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false
mkdir -p /root/.cache/ms-playwright/
if [ -d "/home/openhands/.cache/ms-playwright/" ]; then
mv /home/openhands/.cache/ms-playwright/ /root/.cache/
fi
"$@"
else
echo "Setting up enduser with id $SANDBOX_USER_ID"
@@ -54,6 +58,10 @@ else
fi
mkdir -p /home/enduser/.cache/huggingface/hub/
mkdir -p /home/enduser/.cache/ms-playwright/
if [ -d "/home/openhands/.cache/ms-playwright/" ]; then
mv /home/openhands/.cache/ms-playwright/ /home/enduser/.cache/
fi
usermod -aG $DOCKER_SOCKET_GID enduser
echo "Running as enduser"
+1 -1
View File
@@ -26,7 +26,7 @@ repos:
- id: ruff
entry: ruff check --config dev_config/python/ruff.toml
types_or: [python, pyi, jupyter]
args: [--fix, --unsafe-fixes]
args: [--fix]
# Run the formatter.
- id: ruff-format
entry: ruff format --config dev_config/python/ruff.toml
-3
View File
@@ -7,9 +7,6 @@ select = [
"Q",
"B",
"ASYNC",
"UP006", # Use `list` instead of `List` for annotations
"UP007", # Use `X | Y` instead of `Union[X, Y]`
"UP008", # Use `X | None` instead of `Optional[X]`
]
ignore = [
@@ -42,7 +42,7 @@ Explorez le code source d'OpenHands sur [GitHub](https://github.com/All-Hands-AI
/>
</a>
<br></br>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw">
<img
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
alt="Join our Slack community"
@@ -42,7 +42,7 @@ OpenHandsのソースコードを[GitHub](https://github.com/All-Hands-AI/OpenHa
/>
</a>
<br></br>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw">
<img
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
alt="Slackコミュニティに参加"
@@ -42,7 +42,7 @@ OpenHands 是一个**自主 AI 软件工程师**,能够执行复杂的工程
/>
</a>
<br></br>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw">
<img
src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge"
alt="Join our Slack community"
-103
View File
@@ -1,103 +0,0 @@
# MCP Server for GitHub PR and GitLab MR Creation
This document describes the Model Context Protocol (MCP) server implementation in OpenHands that enables creating pull requests on GitHub and merge requests on GitLab directly from the chat interface.
## Overview
The MCP server provides a standardized interface for creating pull requests and merge requests using the JSON-RPC 2.0 protocol. It integrates with OpenHands' existing GitHub and GitLab clients to handle authentication and API calls.
## Features
- Implements the core MCP protocol using JSON-RPC 2.0
- Provides session management for MCP clients
- Exposes tools for creating pull requests on GitHub and merge requests on GitLab
- Integrates with OpenHands' existing GitHub and GitLab clients
- Follows the MCP specification for capability negotiation and tool definitions
- Properly retrieves GitHub/GitLab tokens from user secrets or environment variables
## Configuration
To configure the MCP server in your OpenHands configuration, add the following to your `config.toml` file:
```toml
[mcp]
# List of MCP SSE servers
sse_servers = [
{
# The URL of the MCP server
url = "http://localhost:12000/mcp",
# Optional API key for authentication (not required for local development)
api_key = ""
}
]
```
## Usage
The MCP server exposes the following tools:
### GitHub Pull Request Creation
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_github_pr",
"arguments": {
"repository": "owner/repo",
"title": "Your PR title",
"body": "Description of your changes",
"head": "your-feature-branch",
"base": "main",
"draft": true
}
}
}
```
### GitLab Merge Request Creation
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_gitlab_mr",
"arguments": {
"project_id": "group/project",
"title": "Your MR title",
"description": "Description of your changes",
"source_branch": "your-feature-branch",
"target_branch": "main",
"draft": true
}
}
}
```
## Authentication
The MCP server retrieves GitHub and GitLab tokens from the following sources, in order of precedence:
1. User secrets stored in the OpenHands settings store
2. Environment variables (`GITHUB_TOKEN` and `GITLAB_TOKEN`)
If no token is found, the server will return an error.
## Microagent Integration
The GitHub and GitLab microagents have been updated to use the MCP server for creating pull requests and merge requests. This ensures that all PR/MR creation requests go through the standardized MCP interface, which provides better security and consistency.
## Implementation Details
The MCP server is implemented as a FastAPI router in `openhands/server/routes/mcp.py`. It handles the following MCP methods:
- `initialize`: Initialize the MCP session and negotiate capabilities
- `shutdown`: Shut down the MCP session
- `listTools`: List available tools
- `callTool`: Call a specific tool with arguments
The server maintains session state for each client, including authentication tokens and service instances.
@@ -49,4 +49,3 @@ The customization options you can set are:
| `OPENHANDS_MACRO` | Variable | Customize default macro for invoking the resolver | `OPENHANDS_MACRO=@resolveit` |
| `OPENHANDS_BASE_CONTAINER_IMAGE` | Variable | Custom Sandbox ([learn more](https://docs.all-hands.dev/modules/usage/how-to/custom-sandbox-guide)) | `OPENHANDS_BASE_CONTAINER_IMAGE="custom_image"` |
| `TARGET_BRANCH` | Variable | Merge to branch other than `main` | `TARGET_BRANCH="dev"` |
| `TARGET_RUNNER` | Variable | Target runner to execute the agent workflow (default ubuntu-latest) | `TARGET_RUNNER="custom-runner"` |
@@ -4,38 +4,6 @@
OpenHands only supports Windows via WSL. Please be sure to run all commands inside your WSL terminal.
:::
### Unable to access VS Code tab via local IP
**Description**
When accessing OpenHands through a non-localhost URL (such as a LAN IP address), the VS Code tab shows a "Forbidden" error, while other parts of the UI work fine.
**Resolution**
This happens because VS Code runs on a random high port that may not be exposed or accessible from other machines. To fix this:
1. Set a specific port for VS Code using the `SANDBOX_VSCODE_PORT` environment variable:
```bash
docker run -it --rm \
-e SANDBOX_VSCODE_PORT=41234 \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:latest \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands-state:/.openhands-state \
-p 3000:3000 \
-p 41234:41234 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:latest
```
2. Make sure to expose the same port with `-p 41234:41234` in your Docker command.
3. Alternatively, you can set this in your `config.toml` file:
```toml
[sandbox]
vscode_port = 41234
```
### Launch docker client failed
**Description**
-5
View File
@@ -215,11 +215,6 @@ const sidebars: SidebarsConfig = {
label: 'Custom Sandbox',
id: 'usage/how-to/custom-sandbox-guide',
},
{
type: 'doc',
label: 'MCP',
id: 'usage/mcp',
}
],
},
{
+1 -1
View File
@@ -8,7 +8,7 @@ function CustomFooter() {
<footer className="custom-footer">
<div className="footer-content">
<div className="footer-icons">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A" target="_blank" rel="noopener noreferrer">
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw" target="_blank" rel="noopener noreferrer">
<FaSlack />
</a>
<a href="https://discord.gg/ESHStjSjD4" target="_blank" rel="noopener noreferrer">
@@ -47,7 +47,7 @@ export function HomepageHeader() {
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers" /></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License" /></a>
<br/>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-34zm4j0gj-Qz5kRHoca8DFCbqXPS~f_A"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-2ngejmfw6-9gW4APWOC9XUp1n~SiQ6iw"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community" /></a>
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community" /></a>
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits" /></a>
<br/>
+21 -45
View File
@@ -1,8 +1,6 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { screen, render } from "@testing-library/react";
import React from "react";
// Mock modules before importing the component
// Mock useParams before importing components
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
@@ -11,11 +9,7 @@ vi.mock("react-router", async () => {
};
});
vi.mock("#/context/conversation-context", () => ({
useConversation: () => ({ conversationId: "test-conversation-id" }),
ConversationProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
@@ -29,56 +23,38 @@ vi.mock("react-i18next", async () => {
};
});
// Mock redux
const mockDispatch = vi.fn();
let mockBrowserState = {
url: "https://example.com",
screenshotSrc: "",
};
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useDispatch: () => mockDispatch,
useSelector: () => mockBrowserState,
};
});
// Import the component after all mocks are set up
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../test-utils";
import { BrowserPanel } from "#/components/features/browser/browser";
describe("Browser", () => {
afterEach(() => {
vi.clearAllMocks();
// Reset the mock state
mockBrowserState = {
url: "https://example.com",
screenshotSrc: "",
};
});
it("renders a message if no screenshotSrc is provided", () => {
// Set the mock state for this test
mockBrowserState = {
url: "https://example.com",
screenshotSrc: "",
};
render(<BrowserPanel />);
renderWithProviders(<BrowserPanel />, {
preloadedState: {
browser: {
url: "https://example.com",
screenshotSrc: "",
},
},
});
// i18n empty message key
expect(screen.getByText("BROWSER$NO_PAGE_LOADED")).toBeInTheDocument();
});
it("renders the url and a screenshot", () => {
// Set the mock state for this test
mockBrowserState = {
url: "https://example.com",
screenshotSrc: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
};
render(<BrowserPanel />);
renderWithProviders(<BrowserPanel />, {
preloadedState: {
browser: {
url: "https://example.com",
screenshotSrc:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mN0uGvyHwAFCAJS091fQwAAAABJRU5ErkJggg==",
},
},
});
expect(screen.getByText("https://example.com")).toBeInTheDocument();
expect(screen.getByAltText("BROWSER$SCREENSHOT_ALT")).toBeInTheDocument();
@@ -34,6 +34,10 @@ describe("ConversationPanel", () => {
}
});
const { endSessionMock } = vi.hoisted(() => ({
endSessionMock: vi.fn(),
}));
beforeAll(() => {
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
@@ -42,6 +46,11 @@ describe("ConversationPanel", () => {
useLocation: vi.fn(() => ({ pathname: "/conversation" })),
useParams: vi.fn(() => ({ conversationId: "2" })),
}));
vi.mock("#/hooks/use-end-session", async (importOriginal) => ({
...(await importOriginal<typeof import("#/hooks/use-end-session")>()),
useEndSession: vi.fn(() => endSessionMock),
}));
});
const mockConversations = [
@@ -136,6 +145,47 @@ describe("ConversationPanel", () => {
expect(cards).toHaveLength(3);
});
it("should call endSession after deleting a conversation that is the current session", async () => {
const user = userEvent.setup();
const mockData = [...mockConversations];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation");
deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id);
if (index !== -1) {
mockData.splice(index, 1);
}
// Wait for React Query to update its cache
await new Promise(resolve => setTimeout(resolve, 0));
});
renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card");
const ellipsisButton = within(cards[1]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const deleteButton = screen.getByTestId("delete-button");
// Click the second delete button
await user.click(deleteButton);
// Confirm the deletion
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument();
// Wait for the cards to update with a longer timeout
await waitFor(() => {
const updatedCards = screen.getAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(2);
}, { timeout: 2000 });
expect(endSessionMock).toHaveBeenCalledOnce();
});
it("should delete a conversation", async () => {
const user = userEvent.setup();
const mockData = [
@@ -53,7 +53,6 @@ describe("HomeHeader", () => {
[],
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
@@ -171,7 +171,6 @@ describe("RepoConnector", () => {
[],
undefined,
undefined,
undefined,
);
});
@@ -95,7 +95,6 @@ describe("TaskCard", () => {
[],
undefined,
MOCK_TASK_1,
undefined,
);
});
});
@@ -13,7 +13,15 @@ describe("App", () => {
{ Component: App, path: "/conversation/:conversationId" },
]);
const { endSessionMock } = vi.hoisted(() => ({
endSessionMock: vi.fn(),
}));
beforeAll(() => {
vi.mock("#/hooks/use-end-session", () => ({
useEndSession: vi.fn(() => endSessionMock),
}));
vi.mock("#/hooks/use-terminal", () => ({
useTerminal: vi.fn(),
}));
@@ -27,4 +35,44 @@ describe("App", () => {
renderWithProviders(<RouteStub initialEntries={["/conversation/123"]} />);
await screen.findByTestId("app-route");
});
it("should call endSession if the user does not have permission to view conversation", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue(null);
renderWithProviders(<RouteStub initialEntries={["/conversation/9999"]} />);
await waitFor(() => {
expect(endSessionMock).toHaveBeenCalledOnce();
expect(errorToastSpy).toHaveBeenCalledOnce();
});
});
it("should not call endSession if the user has permission", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "9999",
last_updated_at: "",
created_at: "",
title: "",
selected_repository: "",
status: "STOPPED",
});
const { rerender } = renderWithProviders(
<RouteStub initialEntries={["/conversation/9999"]} />,
);
await waitFor(() => {
expect(endSessionMock).not.toHaveBeenCalled();
expect(errorToastSpy).not.toHaveBeenCalled();
});
rerender(<RouteStub initialEntries={["/conversation"]} />);
await waitFor(() => {
expect(endSessionMock).not.toHaveBeenCalled();
expect(errorToastSpy).not.toHaveBeenCalled();
});
});
});
+1 -33
View File
@@ -1,16 +1,12 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
import { screen, waitFor, within } from "@testing-library/react";
import {
createAxiosNotFoundErrorObject,
renderWithProviders,
} from "test-utils";
import { renderWithProviders } from "test-utils";
import userEvent from "@testing-library/user-event";
import MainApp from "#/routes/root-layout";
import i18n from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import OpenHands from "#/api/open-hands";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
describe("frontend/routes/_oh", () => {
const RouteStub = createRoutesStub([{ Component: MainApp, path: "/" }]);
@@ -176,32 +172,4 @@ describe("frontend/routes/_oh", () => {
// expect(logoutCleanupSpy).toHaveBeenCalled();
expect(localStorage.getItem("ghToken")).toBeNull();
});
it("should render a you're in toast if it is a new user and in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "test-id",
POSTHOG_CLIENT_KEY: "test-key",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
});
getSettingsSpy.mockRejectedValue(createAxiosNotFoundErrorObject());
renderWithProviders(<RouteStub />);
await waitFor(() => {
expect(displaySuccessToastSpy).toHaveBeenCalledWith("BILLING$YOURE_IN");
expect(displaySuccessToastSpy).toHaveBeenCalledOnce();
});
});
});
+18 -1
View File
@@ -4,13 +4,30 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import { Provider } from "react-redux";
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
import { setupStore } from "test-utils";
import { AxiosError } from "axios";
import HomeScreen from "#/routes/home";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands";
import MainApp from "#/routes/root-layout";
const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
const RouterStub = createRoutesStub([
{
Component: MainApp,
@@ -516,47 +516,6 @@ describe("Form submission", () => {
expect(submitButton).toBeDisabled();
});
});
it("should clear advanced settings when saving basic settings", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
});
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("Anthropic");
await userEvent.click(providerOption);
// select model
await userEvent.click(model);
const modelOption = screen.getByText("claude-3-5-sonnet-20241022");
await userEvent.click(modelOption);
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "anthropic/claude-3-5-sonnet-20241022",
llm_base_url: "",
confirmation_mode: false,
}),
);
});
});
describe("Status toasts", () => {
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.36.1",
"version": "0.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.36.1",
"version": "0.36.0",
"dependencies": {
"@heroui/react": "2.7.8",
"@microlink/react-json-view": "^1.26.1",
@@ -82,7 +82,7 @@
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
@@ -9099,9 +9099,9 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.0.tgz",
"integrity": "sha512-BvQOvUhkVQM1i63iMETK9Hjud9QhqBnbtT1Zc642p9ynzBuCe5pybkOnvqZIBypXmMlsGcnU4HZ8sCTPfpAexA==",
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.6.tgz",
"integrity": "sha512-mUcf7QG2Tjk7H055Jk0lGBjbgDnfrvqjhXh9t2xLMSCjZVcw9Rb1V6sVNXO0th3jgeO7zllWPTNRil3JW94TnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.36.1",
"version": "0.36.0",
"private": true,
"type": "module",
"engines": {
@@ -106,7 +106,7 @@
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.4.0",
"eslint-plugin-prettier": "^5.2.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
+2 -11
View File
@@ -14,7 +14,7 @@ import {
} 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 { GitUser, GitRepository } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
class OpenHands {
@@ -158,13 +158,12 @@ class OpenHands {
imageUrls?: string[],
replayJson?: string,
suggested_task?: SuggestedTask,
selected_branch?: string,
): Promise<Conversation> {
const body = {
conversation_trigger,
repository: selectedRepository,
git_provider,
selected_branch,
selected_branch: undefined,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
replay_json: replayJson,
@@ -317,14 +316,6 @@ class OpenHands {
return data;
}
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
);
return data;
}
}
export default OpenHands;
+3 -1
View File
@@ -1,3 +1,5 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2.25C6.624 2.25 2.25 6.624 2.25 12C2.25 16.376 5.115 20.073 9.057 21.428C9.563 21.514 9.747 21.211 9.747 20.95C9.747 20.713 9.738 19.991 9.738 19.153C7 19.713 6.4 18.45 6.4 18.45C5.952 17.387 5.328 17.084 5.328 17.084C4.476 16.487 5.387 16.487 5.387 16.487C6.328 16.546 6.85 17.481 6.85 17.481C7.675 18.862 9.057 18.487 9.776 18.226C9.867 17.603 10.133 17.179 10.419 16.94C8.287 16.713 6.05 15.85 6.05 11.9C6.05 10.837 6.4 9.975 6.859 9.3C6.759 9.05 6.45 8.038 6.95 6.65C6.95 6.65 7.734 6.4 9.738 7.775C10.483 7.534 11.25 7.413 12.017 7.413C12.784 7.413 13.55 7.534 14.296 7.775C16.3 6.4 17.084 6.65 17.084 6.65C17.584 8.038 17.275 9.05 17.175 9.3C17.634 9.975 17.984 10.837 17.984 11.9C17.984 15.85 15.747 16.7 13.615 16.94C13.975 17.237 14.296 17.813 14.296 18.7C14.296 19.975 14.287 20.6 14.287 20.95C14.287 21.211 14.471 21.514 14.977 21.428C18.919 20.073 21.784 16.376 21.784 12C21.784 6.624 17.376 2.25 12 2.25Z" fill="currentColor"/>
<path
d="M15.359 21V17.319C15.3974 16.8654 15.3314 16.4095 15.1651 15.9814C14.9989 15.5534 14.7363 15.1631 14.3949 14.8364C17.6154 14.5035 21 13.3716 21 8.17826C20.9997 6.85027 20.4489 5.57321 19.4615 4.61139C19.9291 3.44954 19.896 2.16532 19.3692 1.02548C19.3692 1.02548 18.159 0.692576 15.359 2.43321C13.0082 1.84237 10.5302 1.84237 8.17949 2.43321C5.37949 0.692576 4.16923 1.02548 4.16923 1.02548C3.64244 2.16532 3.60938 3.44954 4.07692 4.61139C3.08218 5.58034 2.53079 6.86895 2.53846 8.2068C2.53846 13.3621 5.92308 14.494 9.14359 14.865C8.80615 15.1883 8.54591 15.574 8.3798 15.9968C8.2137 16.4196 8.14544 16.8701 8.17949 17.319V21M8.17949 18.1465C3.05128 19.5732 3.05128 15.7686 1 15.293L8.17949 18.1465Z"
stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 896 B

+5 -7
View File
@@ -1,8 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="97 99 186 182">
<defs>
<style>.cls-1{fill:currentColor;}</style>
</defs>
<g id="LOGO">
<path class="cls-1" d="M282.83,170.73l-.27-.69-26.14-68.22a6.81,6.81,0,0,0-2.69-3.24,7,7,0,0,0-8,.43,7,7,0,0,0-2.32,3.52l-17.65,54H154.29l-17.65-54A6.86,6.86,0,0,0,134.32,99a7,7,0,0,0-8-.43,6.87,6.87,0,0,0-2.69,3.24L97.44,170l-.26.69a48.54,48.54,0,0,0,16.1,56.1l.09.07.24.17,39.82,29.82,19.7,14.91,12,9.06a8.07,8.07,0,0,0,9.76,0l12-9.06,19.7-14.91,40.06-30,.1-.08A48.56,48.56,0,0,0,282.83,170.73Z"/>
</g>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 21L16.5 8H5.5L11 21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 8L3.5 15.5L11 21L18.5 15.5L21 8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1 8L5.5 8L8.25 1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M21 8L16.5 8L13.75 1" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 564 B

After

Width:  |  Height:  |  Size: 534 B

@@ -1,26 +1,12 @@
import { useEffect } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import { RootState } from "#/store";
import { BrowserSnapshot } from "./browser-snapshot";
import { EmptyBrowserMessage } from "./empty-browser-message";
import { useConversation } from "#/context/conversation-context";
import {
initialState as browserInitialState,
setUrl,
setScreenshotSrc,
} from "#/state/browser-slice";
export function BrowserPanel() {
const { url, screenshotSrc } = useSelector(
(state: RootState) => state.browser,
);
const { conversationId } = useConversation();
const dispatch = useDispatch();
useEffect(() => {
dispatch(setUrl(browserInitialState.url));
dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
}, [conversationId]);
const imgSrc =
screenshotSrc && screenshotSrc.startsWith("data:image/png;base64,")
@@ -1,5 +1,5 @@
import React from "react";
import { NavLink, useParams, useNavigate } from "react-router";
import { NavLink, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { ConversationCard } from "./conversation-card";
@@ -8,6 +8,7 @@ import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation"
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { useEndSession } from "#/hooks/use-end-session";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
@@ -17,9 +18,9 @@ interface ConversationPanelProps {
export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { t } = useTranslation();
const { conversationId: currentConversationId } = useParams();
const { conversationId: cid } = useParams();
const endSession = useEndSession();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const navigate = useNavigate();
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
@@ -47,8 +48,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
if (cid === selectedConversationId) {
endSession();
}
},
},
@@ -128,6 +129,7 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {
endSession();
onClose();
}}
onClose={() => setConfirmExitConversationModalVisible(false)}
@@ -4,7 +4,6 @@ import { RepositorySelectionForm } from "./repo-selection-form";
// Create mock functions
const mockUseUserRepositories = vi.fn();
const mockUseRepositoryBranches = vi.fn();
const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
@@ -17,12 +16,6 @@ mockUseUserRepositories.mockReturnValue({
isError: false,
});
mockUseRepositoryBranches.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
@@ -54,10 +47,6 @@ vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/query/use-repository-branches", () => ({
useRepositoryBranches: () => mockUseRepositoryBranches(),
}));
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => mockUseCreateConversation(),
}));
@@ -1,42 +1,79 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import {
RepositoryDropdown,
RepositoryLoadingState,
RepositoryErrorState,
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "./repository-selection";
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
}
// Loading state component
function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}
// Error state component
function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}
// Repository dropdown component
interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
}
function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
/>
);
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const {
mutate: createConversation,
isPending,
@@ -45,27 +82,6 @@ export function RepositorySelectionForm({
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
// Auto-select main or master branch if it exists
React.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, selectedBranch, isLoadingBranches]);
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
@@ -76,11 +92,6 @@ export function RepositorySelectionForm({
label: repo.full_name,
}));
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = repositories?.find(
(repo) => repo.id.toString() === key,
@@ -88,28 +99,15 @@ export function RepositorySelectionForm({
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
};
const handleRepoInputChange = (value: string) => {
const handleInputChange = (value: string) => {
if (value === "") {
setSelectedRepository(null);
setSelectedBranch(null);
onRepoSelection(null);
}
};
const handleBranchInputChange = (value: string) => {
if (value === "") {
setSelectedBranch(null);
}
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
@@ -124,49 +122,15 @@ export function RepositorySelectionForm({
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
/>
);
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState />;
}
if (isBranchesError) {
return <BranchErrorState />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
onInputChange={handleInputChange}
/>
);
};
return (
<div className="flex flex-col gap-4">
<>
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton
testId="repo-launch-button"
variant="primary"
@@ -181,13 +145,12 @@ export function RepositorySelectionForm({
createConversation({
selectedRepository,
conversation_trigger: "gui",
selected_branch: selectedBranch?.name,
})
}
>
{!isCreatingConversation && "Launch"}
{isCreatingConversation && t("HOME$LOADING")}
</BrandButton>
</div>
</>
);
}
@@ -1,32 +0,0 @@
import React from "react";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
}
export function BranchDropdown({
items,
onSelectionChange,
onInputChange,
isDisabled,
selectedKey,
}: BranchDropdownProps) {
return (
<SettingsDropdownInput
testId="branch-dropdown"
name="branch-dropdown"
placeholder="Select a branch"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
/>
);
}
@@ -1,14 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
export function BranchErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>
);
}
@@ -1,16 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
export function BranchLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
</div>
);
}
@@ -1,6 +0,0 @@
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";
@@ -1,26 +0,0 @@
import React from "react";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
export interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
}
export function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
}: RepositoryDropdownProps) {
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder="Select a repo"
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
/>
);
}
@@ -1,14 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
export function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}
@@ -1,16 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
export function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}
@@ -13,7 +13,6 @@ interface SettingsDropdownInputProps {
showOptionalTag?: boolean;
isDisabled?: boolean;
defaultSelectedKey?: string;
selectedKey?: string;
isClearable?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
@@ -29,7 +28,6 @@ export function SettingsDropdownInput({
showOptionalTag,
isDisabled,
defaultSelectedKey,
selectedKey,
isClearable,
onSelectionChange,
onInputChange,
@@ -48,7 +46,6 @@ export function SettingsDropdownInput({
name={name}
defaultItems={items}
defaultSelectedKey={defaultSelectedKey}
selectedKey={selectedKey}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isClearable={isClearable}
@@ -1,23 +1,34 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useDispatch } from "react-redux";
import posthog from "posthog-js";
import { useLocation } from "react-router";
import { NavLink, useLocation } from "react-router";
import { useTranslation } from "react-i18next";
import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions";
import { AllHandsLogoButton } from "#/components/shared/buttons/all-hands-logo-button";
import { DocsButton } from "#/components/shared/buttons/docs-button";
import { NewProjectButton } from "#/components/shared/buttons/new-project-button";
import { ExitProjectButton } from "#/components/shared/buttons/exit-project-button";
import { SettingsButton } from "#/components/shared/buttons/settings-button";
import { ConversationPanelButton } from "#/components/shared/buttons/conversation-panel-button";
import { SettingsModal } from "#/components/shared/modals/settings/settings-modal";
import { useSettings } from "#/hooks/query/use-settings";
import { ConversationPanel } from "../conversation-panel/conversation-panel";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
import { ConversationPanelWrapper } from "../conversation-panel/conversation-panel-wrapper";
import { useLogout } from "#/hooks/mutation/use-logout";
import { useConfig } from "#/hooks/query/use-config";
import { cn } from "#/utils/utils";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { I18nKey } from "#/i18n/declaration";
export function Sidebar() {
const { t } = useTranslation();
const location = useLocation();
const dispatch = useDispatch();
const endSession = useEndSession();
const user = useGitUser();
const { data: config } = useConfig();
const {
@@ -62,6 +73,11 @@ export function Sidebar() {
location.pathname,
]);
const handleEndSession = () => {
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
const handleLogout = async () => {
await logout();
posthog.reset();
@@ -73,18 +89,34 @@ export function Sidebar() {
<nav className="flex flex-row md:flex-col items-center justify-between w-full h-auto md:w-auto md:h-full">
<div className="flex flex-row md:flex-col items-center gap-[26px]">
<div className="flex items-center justify-center">
<AllHandsLogoButton />
<AllHandsLogoButton onClick={handleEndSession} />
</div>
<NewProjectButton />
<ConversationPanelButton
isOpen={conversationPanelIsOpen}
<ExitProjectButton onClick={handleEndSession} />
<TooltipButton
testId="toggle-conversation-panel"
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={() => setConversationPanelIsOpen((prev) => !prev)}
/>
>
<FaListUl
size={22}
className={cn(
conversationPanelIsOpen ? "text-white" : "text-[#9099AC]",
)}
/>
</TooltipButton>
</div>
<div className="flex flex-row md:flex-col md:items-center gap-[26px] md:mb-4">
<DocsButton />
<SettingsButton />
<NavLink
to="/settings"
className={({ isActive }) =>
`${isActive ? "text-white" : "text-[#9099AC]"} mt-0.5 md:mt-0`
}
>
<SettingsButton />
</NavLink>
<UserActions
user={
user.data ? { avatar_url: user.data.avatar_url } : undefined
@@ -1,34 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { getRandomTip } from "#/utils/tips";
export function RandomTip() {
const { t } = useTranslation();
const [randomTip, setRandomTip] = React.useState(getRandomTip());
// Update the random tip when the component mounts
React.useEffect(() => {
setRandomTip(getRandomTip());
}, []);
return (
<p>
<h4 className="font-bold">{t(I18nKey.TIPS$PROTIP)}:</h4>
{t(randomTip.key)}
{randomTip.link && (
<>
{" "}
<a
href={randomTip.link}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t(I18nKey.TIPS$LEARN_MORE)}
</a>
</>
)}
</p>
);
}
@@ -1,72 +0,0 @@
import React, { lazy, Suspense } from "react";
import { useLocation } from "react-router";
import { LoadingSpinner } from "../shared/loading-spinner";
// Lazy load all tab components
const EditorTab = lazy(() => import("#/routes/changes-tab"));
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const TerminalTab = lazy(() => import("#/routes/terminal-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
interface TabContentProps {
conversationPath: string;
}
export function TabContent({ conversationPath }: TabContentProps) {
const location = useLocation();
const currentPath = location.pathname;
// Determine which tab is active based on the current path
const isEditorActive = currentPath === conversationPath;
const isBrowserActive = currentPath === `${conversationPath}/browser`;
const isJupyterActive = currentPath === `${conversationPath}/jupyter`;
const isServedActive = currentPath === `${conversationPath}/served`;
const isTerminalActive = currentPath === `${conversationPath}/terminal`;
const isVSCodeActive = currentPath === `${conversationPath}/vscode`;
return (
<div className="h-full w-full relative">
{/* Each tab content is always loaded but only visible when active */}
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
<LoadingSpinner size="large" />
</div>
}
>
<div
className={`absolute inset-0 ${isEditorActive ? "block" : "hidden"}`}
>
<EditorTab />
</div>
<div
className={`absolute inset-0 ${isBrowserActive ? "block" : "hidden"}`}
>
<BrowserTab />
</div>
<div
className={`absolute inset-0 ${isJupyterActive ? "block" : "hidden"}`}
>
<JupyterTab />
</div>
<div
className={`absolute inset-0 ${isServedActive ? "block" : "hidden"}`}
>
<ServedTab />
</div>
<div
className={`absolute inset-0 ${isTerminalActive ? "block" : "hidden"}`}
>
<TerminalTab />
</div>
<div
className={`absolute inset-0 ${isVSCodeActive ? "block" : "hidden"}`}
>
<VSCodeTab />
</div>
</Suspense>
</div>
);
}
@@ -1,16 +1,19 @@
import { useTranslation } from "react-i18next";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { I18nKey } from "#/i18n/declaration";
import AllHandsLogo from "#/assets/branding/all-hands-logo.svg?react";
import { TooltipButton } from "./tooltip-button";
export function AllHandsLogoButton() {
const { t } = useTranslation();
interface AllHandsLogoButtonProps {
onClick: () => void;
}
export function AllHandsLogoButton({ onClick }: AllHandsLogoButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
tooltip={t(I18nKey.BRANDING$ALL_HANDS_AI)}
ariaLabel={t(I18nKey.BRANDING$ALL_HANDS_LOGO)}
navLinkTo="/"
onClick={onClick}
>
<AllHandsLogo width={34} height={34} />
</TooltipButton>
@@ -1,32 +0,0 @@
import React from "react";
import { FaListUl } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "./tooltip-button";
import { cn } from "#/utils/utils";
interface ConversationPanelButtonProps {
isOpen: boolean;
onClick: () => void;
}
export function ConversationPanelButton({
isOpen,
onClick,
}: ConversationPanelButtonProps) {
const { t } = useTranslation();
return (
<TooltipButton
testId="toggle-conversation-panel"
tooltip={t(I18nKey.SIDEBAR$CONVERSATIONS)}
ariaLabel={t(I18nKey.SIDEBAR$CONVERSATIONS)}
onClick={onClick}
>
<FaListUl
size={22}
className={cn(isOpen ? "text-white" : "text-[#9099AC]")}
/>
</TooltipButton>
);
}
@@ -3,17 +3,21 @@ import { I18nKey } from "#/i18n/declaration";
import PlusIcon from "#/icons/plus.svg?react";
import { TooltipButton } from "./tooltip-button";
export function NewProjectButton() {
interface ExitProjectButtonProps {
onClick: () => void;
}
export function ExitProjectButton({ onClick }: ExitProjectButtonProps) {
const { t } = useTranslation();
const startNewProject = t(I18nKey.CONVERSATION$START_NEW);
return (
<TooltipButton
tooltip={startNewProject}
ariaLabel={startNewProject}
navLinkTo="/"
onClick={onClick}
testId="new-project-button"
>
<PlusIcon width={28} height={28} />
<PlusIcon width={28} height={28} className="text-[#9099AC]" />
</TooltipButton>
);
}
@@ -16,7 +16,6 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
tooltip={t(I18nKey.SETTINGS$TITLE)}
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
>
<SettingsIcon width={28} height={28} />
</TooltipButton>
@@ -1,14 +1,12 @@
import { Tooltip } from "@heroui/react";
import React, { ReactNode } from "react";
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
export interface TooltipButtonProps {
interface TooltipButtonProps {
children: ReactNode;
tooltip: string;
onClick?: () => void;
href?: string;
navLinkTo?: string;
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
@@ -19,66 +17,35 @@ export function TooltipButton({
tooltip,
onClick,
href,
navLinkTo,
ariaLabel,
testId,
className,
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
onClick();
e.preventDefault();
}
};
const buttonContent = (
<button
type="button"
aria-label={ariaLabel}
data-testid={testId}
onClick={handleClick}
onClick={onClick}
className={cn("hover:opacity-80", className)}
>
{children}
</button>
);
let content;
if (navLinkTo) {
content = (
<NavLink
to={navLinkTo}
onClick={handleClick}
className={({ isActive }) =>
cn(
"hover:opacity-80",
isActive ? "text-white" : "text-[#9099AC]",
className,
)
}
aria-label={ariaLabel}
data-testid={testId}
>
{children}
</NavLink>
);
} else if (href) {
content = (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className={cn("hover:opacity-80", className)}
aria-label={ariaLabel}
data-testid={testId}
>
{children}
</a>
);
} else {
content = buttonContent;
}
const content = href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className={cn("hover:opacity-80", className)}
aria-label={ariaLabel}
>
{children}
</a>
) : (
buttonContent
);
return (
<Tooltip content={tooltip} closeDelay={100} placement="right">
@@ -0,0 +1,45 @@
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useEndSession } from "#/hooks/use-end-session";
import { setCurrentAgentState } from "#/state/agent-slice";
import { AgentState } from "#/types/agent-state";
import { DangerModal } from "./confirmation-modals/danger-modal";
import { ModalBackdrop } from "./modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
interface ExitProjectConfirmationModalProps {
onClose: () => void;
}
export function ExitProjectConfirmationModal({
onClose,
}: ExitProjectConfirmationModalProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
const endSession = useEndSession();
const handleEndSession = () => {
onClose();
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
};
return (
<ModalBackdrop onClose={onClose}>
<DangerModal
title={t(I18nKey.EXIT_PROJECT$CONFIRM)}
description={t(I18nKey.EXIT_PROJECT$WARNING)}
buttons={{
danger: {
text: t(I18nKey.EXIT_PROJECT$TITLE),
onClick: handleEndSession,
},
cancel: {
text: t(I18nKey.BUTTON$CANCEL),
onClick: onClose,
},
}}
/>
</ModalBackdrop>
);
}
@@ -6,6 +6,7 @@ import { I18nKey } from "#/i18n/declaration";
import { organizeModelsAndProviders } from "#/utils/organize-models-and-providers";
import { DangerModal } from "../confirmation-modals/danger-modal";
import { extractSettings } from "#/utils/settings-utils";
import { useEndSession } from "#/hooks/use-end-session";
import { ModalBackdrop } from "../modal-backdrop";
import { ModelSelector } from "./model-selector";
import { Settings } from "#/types/settings";
@@ -23,6 +24,7 @@ interface SettingsFormProps {
export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const { mutate: saveUserSettings } = useSaveSettings();
const endSession = useEndSession();
const location = useLocation();
const { t } = useTranslation();
@@ -32,12 +34,19 @@ export function SettingsForm({ settings, models, onClose }: SettingsFormProps) {
const [confirmEndSessionModalOpen, setConfirmEndSessionModalOpen] =
React.useState(false);
const resetOngoingSession = () => {
if (location.pathname.startsWith("/conversations/")) {
endSession();
}
};
const handleFormSubmission = async (formData: FormData) => {
const newSettings = extractSettings(formData);
await saveUserSettings(newSettings, {
onSuccess: () => {
onClose();
resetOngoingSession();
posthog.capture("settings_saved", {
LLM_MODEL: newSettings.LLM_MODEL,
@@ -24,7 +24,7 @@ export const useCreateConversation = () => {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
selected_branch?: string;
suggested_task?: SuggestedTask;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
@@ -41,7 +41,6 @@ export const useCreateConversation = () => {
files,
replayJson || undefined,
variables.suggested_task || undefined,
variables.selected_branch,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
@@ -20,8 +20,6 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
user_consents_to_analytics: settings.user_consents_to_analytics,
enable_proactive_conversation_starters:
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
};
await OpenHands.saveSettings(apiSettings);
@@ -1,14 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { Branch } from "#/types/git";
export const useRepositoryBranches = (repository: string | null) =>
useQuery<Branch[]>({
queryKey: ["repository", repository, "branches"],
queryFn: async () => {
if (!repository) return [];
return OpenHands.getRepositoryBranches(repository);
},
enabled: !!repository,
staleTime: 1000 * 60 * 5, // 5 minutes
});
-2
View File
@@ -22,8 +22,6 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
apiSettings.enable_proactive_conversation_starters,
USER_CONSENTS_TO_ANALYTICS: apiSettings.user_consents_to_analytics,
IS_NEW_USER: false,
};
+7 -31
View File
@@ -1,43 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { useConversation } from "#/context/conversation-context";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
// Define the return type for the VS Code URL query
interface VSCodeUrlResult {
url: string | null;
error: string | null;
}
export const useVSCodeUrl = () => {
const { t } = useTranslation();
export const useVSCodeUrl = (config: { enabled: boolean }) => {
const { conversationId } = useConversation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
return useQuery<VSCodeUrlResult>({
const data = useQuery({
queryKey: ["vscode_url", conversationId],
queryFn: async () => {
queryFn: () => {
if (!conversationId) throw new Error("No conversation ID");
const data = await OpenHands.getVSCodeUrl(conversationId);
if (data.vscode_url) {
return {
url: transformVSCodeUrl(data.vscode_url),
error: null,
};
}
return {
url: null,
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
};
return OpenHands.getVSCodeUrl(conversationId);
},
enabled: !!conversationId && !isRuntimeInactive,
enabled: !!conversationId && config.enabled,
refetchOnMount: true,
retry: 3,
});
return data;
};
+28
View File
@@ -0,0 +1,28 @@
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router";
import {
initialState as browserInitialState,
setScreenshotSrc,
setUrl,
} from "#/state/browser-slice";
import { clearSelectedRepository } from "#/state/initial-query-slice";
export const useEndSession = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
/**
* End the current session by clearing the token and redirecting to the home page.
*/
const endSession = () => {
dispatch(clearSelectedRepository());
// Reset browser state to initial values
dispatch(setUrl(browserInitialState.url));
dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
navigate("/");
};
return endSession;
};
@@ -5,6 +5,7 @@ import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { addErrorMessage } from "#/state/chat-slice";
import { AgentState } from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { useEndSession } from "./use-end-session";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
interface ServerError {
@@ -20,6 +21,7 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
export const useHandleWSEvents = () => {
const { events, send } = useWsClient();
const endSession = useEndSession();
const dispatch = useDispatch();
React.useEffect(() => {
@@ -31,6 +33,7 @@ export const useHandleWSEvents = () => {
if (isServerError(event)) {
if (event.error_code === 401) {
displayErrorToast("Session expired.");
endSession();
return;
}
-16
View File
@@ -8,8 +8,6 @@ export enum I18nKey {
HOME$LOADING = "HOME$LOADING",
HOME$LOADING_REPOSITORIES = "HOME$LOADING_REPOSITORIES",
HOME$FAILED_TO_LOAD_REPOSITORIES = "HOME$FAILED_TO_LOAD_REPOSITORIES",
HOME$LOADING_BRANCHES = "HOME$LOADING_BRANCHES",
HOME$FAILED_TO_LOAD_BRANCHES = "HOME$FAILED_TO_LOAD_BRANCHES",
HOME$OPEN_ISSUE = "HOME$OPEN_ISSUE",
HOME$FIX_FAILING_CHECKS = "HOME$FIX_FAILING_CHECKS",
HOME$RESOLVE_MERGE_CONFLICTS = "HOME$RESOLVE_MERGE_CONFLICTS",
@@ -70,7 +68,6 @@ export enum I18nKey {
SETTINGS$LLM_SETTINGS = "SETTINGS$LLM_SETTINGS",
SETTINGS$GIT_SETTINGS = "SETTINGS$GIT_SETTINGS",
SETTINGS$SOUND_NOTIFICATIONS = "SETTINGS$SOUND_NOTIFICATIONS",
SETTINGS$PROACTIVE_CONVERSATION_STARTERS = "SETTINGS$PROACTIVE_CONVERSATION_STARTERS",
SETTINGS$CUSTOM_MODEL = "SETTINGS$CUSTOM_MODEL",
GITHUB$CODE_NOT_IN_GITHUB = "GITHUB$CODE_NOT_IN_GITHUB",
GITHUB$START_FROM_SCRATCH = "GITHUB$START_FROM_SCRATCH",
@@ -313,7 +310,6 @@ export enum I18nKey {
STATUS$PREPARING_CONTAINER = "STATUS$PREPARING_CONTAINER",
STATUS$CONTAINER_STARTED = "STATUS$CONTAINER_STARTED",
STATUS$SETTING_UP_WORKSPACE = "STATUS$SETTING_UP_WORKSPACE",
STATUS$SETTING_UP_GIT_HOOKS = "STATUS$SETTING_UP_GIT_HOOKS",
ACCOUNT_SETTINGS_MODAL$DISCONNECT = "ACCOUNT_SETTINGS_MODAL$DISCONNECT",
ACCOUNT_SETTINGS_MODAL$SAVE = "ACCOUNT_SETTINGS_MODAL$SAVE",
ACCOUNT_SETTINGS_MODAL$CLOSE = "ACCOUNT_SETTINGS_MODAL$CLOSE",
@@ -478,16 +474,4 @@ export enum I18nKey {
TOS$ACCEPT_TERMS_DESCRIPTION = "TOS$ACCEPT_TERMS_DESCRIPTION",
TOS$CONTINUE = "TOS$CONTINUE",
TOS$ERROR_ACCEPTING = "TOS$ERROR_ACCEPTING",
TIPS$CUSTOMIZE_MICROAGENT = "TIPS$CUSTOMIZE_MICROAGENT",
TIPS$SETUP_SCRIPT = "TIPS$SETUP_SCRIPT",
TIPS$VSCODE_INSTANCE = "TIPS$VSCODE_INSTANCE",
TIPS$SAVE_WORK = "TIPS$SAVE_WORK",
TIPS$SPECIFY_FILES = "TIPS$SPECIFY_FILES",
TIPS$HEADLESS_MODE = "TIPS$HEADLESS_MODE",
TIPS$CLI_MODE = "TIPS$CLI_MODE",
TIPS$GITHUB_HOOK = "TIPS$GITHUB_HOOK",
TIPS$BLOG_SIGNUP = "TIPS$BLOG_SIGNUP",
TIPS$API_USAGE = "TIPS$API_USAGE",
TIPS$LEARN_MORE = "TIPS$LEARN_MORE",
TIPS$PROTIP = "TIPS$PROTIP",
}
-240
View File
@@ -119,36 +119,6 @@
"tr": "Depolar yüklenemedi",
"de": "Fehler beim Laden der Repositories"
},
"HOME$LOADING_BRANCHES": {
"en": "Loading branches...",
"ja": "ブランチを読み込み中...",
"zh-CN": "正在加载分支...",
"zh-TW": "正在加載分支...",
"ko-KR": "브랜치 로딩 중...",
"no": "Laster inn branches...",
"it": "Caricamento dei branch...",
"pt": "Carregando branches...",
"es": "Cargando ramas...",
"ar": "جاري تحميل الفروع...",
"fr": "Chargement des branches...",
"tr": "Dallar yükleniyor...",
"de": "Lade Branches..."
},
"HOME$FAILED_TO_LOAD_BRANCHES": {
"en": "Failed to load branches",
"ja": "ブランチの読み込みに失敗しました",
"zh-CN": "加载分支失败",
"zh-TW": "加載分支失敗",
"ko-KR": "브랜치 로딩 실패",
"no": "Kunne ikke laste inn branches",
"it": "Impossibile caricare i branch",
"pt": "Falha ao carregar branches",
"es": "Error al cargar ramas",
"ar": "فشل في تحميل الفروع",
"fr": "Échec du chargement des branches",
"tr": "Dallar yüklenemedi",
"de": "Fehler beim Laden der Branches"
},
"HOME$OPEN_ISSUE": {
"en": "Open issue",
"ja": "オープンな課題",
@@ -1052,21 +1022,6 @@
"fr": "Notifications sonores",
"tr": "Ses Bildirimleri"
},
"SETTINGS$PROACTIVE_CONVERSATION_STARTERS": {
"en": "Suggest Tasks on GitHub",
"ja": "GitHubでタスクを提案",
"zh-CN": "在GitHub上推荐任务",
"zh-TW": "在GitHub上推薦任務",
"ko-KR": "GitHub에서 작업 제안",
"de": "Aufgaben auf GitHub vorschlagen",
"no": "Foreslå oppgaver på GitHub",
"it": "Suggerisci attività su GitHub",
"pt": "Sugerir tarefas no GitHub",
"es": "Sugerir tareas en GitHub",
"ar": "اقتراح المهام على GitHub",
"fr": "Suggérer des tâches sur GitHub",
"tr": "GitHub'da Görevler Öner"
},
"SETTINGS$CUSTOM_MODEL": {
"en": "Custom Model",
"ja": "カスタムモデル",
@@ -4433,21 +4388,6 @@
"tr": "Çalışma alanı ayarlanıyor...",
"ja": "ワークスペースを設定中..."
},
"STATUS$SETTING_UP_GIT_HOOKS": {
"en": "Setting up git hooks...",
"zh-CN": "正在设置 git 钩子...",
"zh-TW": "正在設置 git 鉤子...",
"de": "Git-Hooks werden eingerichtet...",
"ko-KR": "git 훅을 설정하는 중...",
"no": "Setter opp git-hooks...",
"it": "Configurazione degli hook git...",
"pt": "Configurando hooks do git...",
"es": "Configurando hooks de git...",
"ar": "جاري إعداد خطافات git...",
"fr": "Configuration des hooks git...",
"tr": "Git kancaları ayarlanıyor...",
"ja": "git フックを設定中..."
},
"ACCOUNT_SETTINGS_MODAL$DISCONNECT": {
"en": "Disconnect",
"es": "Desconectar",
@@ -6877,185 +6817,5 @@
"de": "Fehler beim Akzeptieren der Nutzungsbedingungen",
"it": "Errore nell'accettazione dei Termini di Servizio",
"pt": "Erro ao aceitar os Termos de Serviço"
},
"TIPS$CUSTOMIZE_MICROAGENT": {
"en": "You can customize OpenHands for your repo using a microagent. Ask OpenHands to put a description of the repo, including how to run the code, into .openhands/microagents/repo.md.",
"ja": "マイクロエージェントを使用して、リポジトリ用にOpenHandsをカスタマイズできます。OpenHandsに、コードの実行方法を含むリポジトリの説明を.openhands/microagents/repo.mdに入れるよう依頼してください。",
"zh-CN": "您可以使用微代理为您的仓库自定义OpenHands。请OpenHands将仓库的描述(包括如何运行代码)放入.openhands/microagents/repo.md。",
"zh-TW": "您可以使用微代理為您的倉庫自定義OpenHands。請OpenHands將倉庫的描述(包括如何運行代碼)放入.openhands/microagents/repo.md。",
"ko-KR": "마이크로에이전트를 사용하여 저장소에 맞게 OpenHands를 사용자 정의할 수 있습니다. OpenHands에게 코드 실행 방법을 포함한 저장소 설명을 .openhands/microagents/repo.md에 넣도록 요청하세요.",
"no": "Du kan tilpasse OpenHands for ditt repo ved å bruke en mikroagent. Be OpenHands om å legge en beskrivelse av repoet, inkludert hvordan du kjører koden, i .openhands/microagents/repo.md.",
"it": "Puoi personalizzare OpenHands per il tuo repository utilizzando un microagente. Chiedi a OpenHands di inserire una descrizione del repository, incluso come eseguire il codice, in .openhands/microagents/repo.md.",
"pt": "Você pode personalizar o OpenHands para seu repositório usando um microagente. Peça ao OpenHands para colocar uma descrição do repositório, incluindo como executar o código, em .openhands/microagents/repo.md.",
"es": "Puede personalizar OpenHands para su repositorio utilizando un microagente. Pídale a OpenHands que ponga una descripción del repositorio, incluido cómo ejecutar el código, en .openhands/microagents/repo.md.",
"ar": "يمكنك تخصيص OpenHands لمستودعك باستخدام وكيل مصغر. اطلب من OpenHands وضع وصف للمستودع، بما في ذلك كيفية تشغيل الكود، في .openhands/microagents/repo.md.",
"fr": "Vous pouvez personnaliser OpenHands pour votre dépôt en utilisant un micro-agent. Demandez à OpenHands de mettre une description du dépôt, y compris comment exécuter le code, dans .openhands/microagents/repo.md.",
"tr": "Bir mikro ajan kullanarak deponuz için OpenHands'i özelleştirebilirsiniz. OpenHands'ten kodun nasıl çalıştırılacağı da dahil olmak üzere deponun açıklamasını .openhands/microagents/repo.md dosyasına koymasını isteyin.",
"de": "Sie können OpenHands für Ihr Repository mit einem Mikroagenten anpassen. Bitten Sie OpenHands, eine Beschreibung des Repositorys, einschließlich der Ausführung des Codes, in .openhands/microagents/repo.md zu platzieren."
},
"TIPS$SETUP_SCRIPT": {
"en": "You can add .openhands/setup.sh to your repository to automatically run a setup script every time you start an OpenHands conversation.",
"ja": "OpenHandsの会話を開始するたびに自動的にセットアップスクリプトを実行するために、.openhands/setup.shをリポジトリに追加できます。",
"zh-CN": "您可以将.openhands/setup.sh添加到您的仓库中,以便在每次启动OpenHands对话时自动运行设置脚本。",
"zh-TW": "您可以將.openhands/setup.sh添加到您的倉庫中,以便在每次啟動OpenHands對話時自動運行設置腳本。",
"ko-KR": "OpenHands 대화를 시작할 때마다 자동으로 설정 스크립트를 실행하도록 .openhands/setup.sh를 저장소에 추가할 수 있습니다.",
"no": "Du kan legge til .openhands/setup.sh i ditt repository for å automatisk kjøre et oppsettskript hver gang du starter en OpenHands-samtale.",
"it": "Puoi aggiungere .openhands/setup.sh al tuo repository per eseguire automaticamente uno script di configurazione ogni volta che avvii una conversazione OpenHands.",
"pt": "Você pode adicionar .openhands/setup.sh ao seu repositório para executar automaticamente um script de configuração toda vez que iniciar uma conversa OpenHands.",
"es": "Puede agregar .openhands/setup.sh a su repositorio para ejecutar automáticamente un script de configuración cada vez que inicie una conversación de OpenHands.",
"ar": "يمكنك إضافة .openhands/setup.sh إلى مستودعك لتشغيل نص إعداد تلقائيًا في كل مرة تبدأ فيها محادثة OpenHands.",
"fr": "Vous pouvez ajouter .openhands/setup.sh à votre dépôt pour exécuter automatiquement un script de configuration chaque fois que vous démarrez une conversation OpenHands.",
"tr": "OpenHands konuşması başlattığınız her seferinde otomatik olarak bir kurulum betiği çalıştırmak için deponuza .openhands/setup.sh ekleyebilirsiniz.",
"de": "Sie können .openhands/setup.sh zu Ihrem Repository hinzufügen, um jedes Mal, wenn Sie ein OpenHands-Gespräch starten, automatisch ein Setup-Skript auszuführen."
},
"TIPS$VSCODE_INSTANCE": {
"en": "Every OpenHands conversation comes with a VS Code instance, where you can interact with the development environment.",
"ja": "すべてのOpenHands会話には、開発環境と対話できるVS Codeインスタンスが付属しています。",
"zh-CN": "每个OpenHands对话都配有VS Code实例,您可以在其中与开发环境交互。",
"zh-TW": "每個OpenHands對話都配有VS Code實例,您可以在其中與開發環境交互。",
"ko-KR": "모든 OpenHands 대화에는 개발 환경과 상호 작용할 수 있는 VS Code 인스턴스가 함께 제공됩니다.",
"no": "Hver OpenHands-samtale kommer med en VS Code-instans, hvor du kan samhandle med utviklingsmiljøet.",
"it": "Ogni conversazione OpenHands è dotata di un'istanza VS Code, dove puoi interagire con l'ambiente di sviluppo.",
"pt": "Cada conversa OpenHands vem com uma instância do VS Code, onde você pode interagir com o ambiente de desenvolvimento.",
"es": "Cada conversación de OpenHands viene con una instancia de VS Code, donde puede interactuar con el entorno de desarrollo.",
"ar": "تأتي كل محادثة OpenHands مع نسخة من VS Code، حيث يمكنك التفاعل مع بيئة التطوير.",
"fr": "Chaque conversation OpenHands est accompagnée d'une instance VS Code, où vous pouvez interagir avec l'environnement de développement.",
"tr": "Her OpenHands konuşması, geliştirme ortamıyla etkileşimde bulunabileceğiniz bir VS Code örneği ile birlikte gelir.",
"de": "Jedes OpenHands-Gespräch wird mit einer VS Code-Instanz geliefert, in der Sie mit der Entwicklungsumgebung interagieren können."
},
"TIPS$SAVE_WORK": {
"en": "Be sure to regularly save your work, either by pushing to GitHub or by downloading your files via VS Code.",
"ja": "GitHubにプッシュするか、VS Codeを介してファイルをダウンロードすることで、定期的に作業を保存してください。",
"zh-CN": "请确保定期保存您的工作,可以通过推送到GitHub或通过VS Code下载文件来实现。",
"zh-TW": "請確保定期保存您的工作,可以通過推送到GitHub或通過VS Code下載文件來實現。",
"ko-KR": "GitHub에 푸시하거나 VS Code를 통해 파일을 다운로드하여 정기적으로 작업을 저장하세요.",
"no": "Sørg for å lagre arbeidet ditt regelmessig, enten ved å pushe til GitHub eller ved å laste ned filene dine via VS Code.",
"it": "Assicurati di salvare regolarmente il tuo lavoro, sia inviando a GitHub o scaricando i tuoi file tramite VS Code.",
"pt": "Certifique-se de salvar regularmente seu trabalho, seja enviando para o GitHub ou baixando seus arquivos via VS Code.",
"es": "Asegúrese de guardar regularmente su trabajo, ya sea enviándolo a GitHub o descargando sus archivos a través de VS Code.",
"ar": "تأكد من حفظ عملك بانتظام، إما عن طريق الدفع إلى GitHub أو عن طريق تنزيل ملفاتك عبر VS Code.",
"fr": "Assurez-vous de sauvegarder régulièrement votre travail, soit en le poussant vers GitHub, soit en téléchargeant vos fichiers via VS Code.",
"tr": "GitHub'a göndererek veya VS Code aracılığıyla dosyalarınızı indirerek çalışmalarınızı düzenli olarak kaydettiğinizden emin olun.",
"de": "Stellen Sie sicher, dass Sie Ihre Arbeit regelmäßig speichern, entweder durch Pushen zu GitHub oder durch Herunterladen Ihrer Dateien über VS Code."
},
"TIPS$SPECIFY_FILES": {
"en": "When possible, include the names of files or functions OpenHands should focus on. This can help OpenHands work faster, save money, and improve accuracy.",
"ja": "可能な場合は、OpenHandsが集中すべきファイルや関数の名前を含めてください。これにより、OpenHandsの作業が速くなり、コストを節約し、精度を向上させることができます。",
"zh-CN": "如果可能,请包含OpenHands应该关注的文件或函数的名称。这可以帮助OpenHands更快地工作,节省成本,并提高准确性。",
"zh-TW": "如果可能,請包含OpenHands應該關注的文件或函數的名稱。這可以幫助OpenHands更快地工作,節省成本,並提高準確性。",
"ko-KR": "가능한 경우, OpenHands가 집중해야 할 파일이나 함수의 이름을 포함하세요. 이는 OpenHands가 더 빠르게 작업하고, 비용을 절약하며, 정확도를 향상시키는 데 도움이 됩니다.",
"no": "Når det er mulig, inkluder navnene på filer eller funksjoner OpenHands bør fokusere på. Dette kan hjelpe OpenHands å jobbe raskere, spare penger og forbedre nøyaktigheten.",
"it": "Quando possibile, includi i nomi dei file o delle funzioni su cui OpenHands dovrebbe concentrarsi. Questo può aiutare OpenHands a lavorare più velocemente, risparmiare denaro e migliorare la precisione.",
"pt": "Quando possível, inclua os nomes dos arquivos ou funções nos quais o OpenHands deve se concentrar. Isso pode ajudar o OpenHands a trabalhar mais rápido, economizar dinheiro e melhorar a precisão.",
"es": "Cuando sea posible, incluya los nombres de los archivos o funciones en los que OpenHands debe centrarse. Esto puede ayudar a OpenHands a trabajar más rápido, ahorrar dinero y mejorar la precisión.",
"ar": "عندما يكون ذلك ممكنًا، قم بتضمين أسماء الملفات أو الوظائف التي يجب أن يركز عليها OpenHands. يمكن أن يساعد ذلك OpenHands على العمل بشكل أسرع، وتوفير المال، وتحسين الدقة.",
"fr": "Lorsque c'est possible, incluez les noms des fichiers ou des fonctions sur lesquels OpenHands devrait se concentrer. Cela peut aider OpenHands à travailler plus rapidement, à économiser de l'argent et à améliorer la précision.",
"tr": "Mümkün olduğunda, OpenHands'in odaklanması gereken dosya veya fonksiyon isimlerini dahil edin. Bu, OpenHands'in daha hızlı çalışmasına, para tasarrufu sağlamasına ve doğruluğu artırmasına yardımcı olabilir.",
"de": "Wenn möglich, geben Sie die Namen der Dateien oder Funktionen an, auf die sich OpenHands konzentrieren soll. Dies kann OpenHands helfen, schneller zu arbeiten, Geld zu sparen und die Genauigkeit zu verbessern."
},
"TIPS$HEADLESS_MODE": {
"en": "You can run OpenHands in headless mode to create automations, like responding to 500 errors by automatically creating a fix.",
"ja": "OpenHandsをヘッドレスモードで実行して、500エラーに対して自動的に修正を作成するなどの自動化を作成できます。",
"zh-CN": "您可以在无头模式下运行OpenHands来创建自动化,例如通过自动创建修复来响应500错误。",
"zh-TW": "您可以在無頭模式下運行OpenHands來創建自動化,例如通過自動創建修復來響應500錯誤。",
"ko-KR": "OpenHands를 헤드리스 모드로 실행하여 500 오류에 자동으로 수정을 생성하는 등의 자동화를 만들 수 있습니다.",
"no": "Du kan kjøre OpenHands i headless-modus for å lage automatiseringer, som å svare på 500-feil ved å automatisk opprette en løsning.",
"it": "Puoi eseguire OpenHands in modalità headless per creare automazioni, come rispondere agli errori 500 creando automaticamente una correzione.",
"pt": "Você pode executar o OpenHands no modo headless para criar automações, como responder a erros 500 criando automaticamente uma correção.",
"es": "Puede ejecutar OpenHands en modo headless para crear automatizaciones, como responder a errores 500 creando automáticamente una solución.",
"ar": "يمكنك تشغيل OpenHands في الوضع اللارأسي لإنشاء عمليات آلية، مثل الاستجابة لأخطاء 500 عن طريق إنشاء إصلاح تلقائيًا.",
"fr": "Vous pouvez exécuter OpenHands en mode headless pour créer des automatisations, comme répondre aux erreurs 500 en créant automatiquement un correctif.",
"tr": "OpenHands'i başsız modda çalıştırarak, 500 hatalarına otomatik olarak düzeltme oluşturarak yanıt vermek gibi otomasyonlar oluşturabilirsiniz.",
"de": "Sie können OpenHands im Headless-Modus ausführen, um Automatisierungen zu erstellen, wie z.B. das Reagieren auf 500-Fehler durch automatisches Erstellen einer Lösung."
},
"TIPS$CLI_MODE": {
"en": "You can run OpenHands as a CLI, similar to Claude Code.",
"ja": "Claude Codeと同様に、OpenHandsをCLIとして実行できます。",
"zh-CN": "您可以将OpenHands作为CLI运行,类似于Claude Code。",
"zh-TW": "您可以將OpenHands作為CLI運行,類似於Claude Code。",
"ko-KR": "Claude Code와 유사하게 OpenHands를 CLI로 실행할 수 있습니다.",
"no": "Du kan kjøre OpenHands som en CLI, lignende Claude Code.",
"it": "Puoi eseguire OpenHands come CLI, simile a Claude Code.",
"pt": "Você pode executar o OpenHands como CLI, semelhante ao Claude Code.",
"es": "Puede ejecutar OpenHands como CLI, similar a Claude Code.",
"ar": "يمكنك تشغيل OpenHands كواجهة سطر أوامر، مشابهة لـ Claude Code.",
"fr": "Vous pouvez exécuter OpenHands en tant que CLI, similaire à Claude Code.",
"tr": "OpenHands'i Claude Code'a benzer şekilde bir CLI olarak çalıştırabilirsiniz.",
"de": "Sie können OpenHands als CLI ausführen, ähnlich wie Claude Code."
},
"TIPS$GITHUB_HOOK": {
"en": "OpenHands Cloud offers a GitHub hook, so you can say \"@openhands fix the merge conflicts\" or \"@openhands fix the feedback on this PR\" right inside the GitHub UI.",
"ja": "OpenHands CloudはGitHubフックを提供しているため、GitHub UI内で「@openhands マージの競合を修正して」や「@openhands このPRのフィードバックを修正して」と言うことができます。",
"zh-CN": "OpenHands Cloud提供GitHub钩子,因此您可以在GitHub UI中直接说\"@openhands 修复合并冲突\"或\"@openhands 修复此PR上的反馈\"。",
"zh-TW": "OpenHands Cloud提供GitHub鉤子,因此您可以在GitHub UI中直接說\"@openhands 修復合併衝突\"或\"@openhands 修復此PR上的反饋\"。",
"ko-KR": "OpenHands Cloud는 GitHub 훅을 제공하므로 GitHub UI 내에서 \"@openhands 병합 충돌 수정\" 또는 \"@openhands 이 PR의 피드백 수정\"이라고 말할 수 있습니다.",
"no": "OpenHands Cloud tilbyr en GitHub-hook, så du kan si \"@openhands fix the merge conflicts\" eller \"@openhands fix the feedback on this PR\" direkte i GitHub-grensesnittet.",
"it": "OpenHands Cloud offre un hook GitHub, così puoi dire \"@openhands fix the merge conflicts\" o \"@openhands fix the feedback on this PR\" direttamente nell'interfaccia di GitHub.",
"pt": "O OpenHands Cloud oferece um hook do GitHub, para que você possa dizer \"@openhands fix the merge conflicts\" ou \"@openhands fix the feedback on this PR\" diretamente na interface do GitHub.",
"es": "OpenHands Cloud ofrece un hook de GitHub, por lo que puede decir \"@openhands fix the merge conflicts\" o \"@openhands fix the feedback on this PR\" directamente en la interfaz de GitHub.",
"ar": "يوفر OpenHands Cloud خطافًا لـ GitHub، لذلك يمكنك قول \"@openhands أصلح تعارضات الدمج\" أو \"@openhands أصلح التعليقات على طلب السحب هذا\" مباشرة داخل واجهة GitHub.",
"fr": "OpenHands Cloud propose un hook GitHub, vous pouvez donc dire \"@openhands fix the merge conflicts\" ou \"@openhands fix the feedback on this PR\" directement dans l'interface GitHub.",
"tr": "OpenHands Cloud, GitHub kancası sunar, böylece GitHub arayüzünde doğrudan \"@openhands birleştirme çakışmalarını düzelt\" veya \"@openhands bu PR'daki geri bildirimi düzelt\" diyebilirsiniz.",
"de": "OpenHands Cloud bietet einen GitHub-Hook, sodass Sie \"@openhands fix the merge conflicts\" oder \"@openhands fix the feedback on this PR\" direkt in der GitHub-Benutzeroberfläche sagen können."
},
"TIPS$BLOG_SIGNUP": {
"en": "Sign up for the OpenHands Blog to hear about new features and the latest releases.",
"ja": "OpenHandsブログに登録して、新機能や最新リリースについての情報を入手しましょう。",
"zh-CN": "注册OpenHands博客,了解新功能和最新版本。",
"zh-TW": "註冊OpenHands博客,了解新功能和最新版本。",
"ko-KR": "OpenHands 블로그에 가입하여 새로운 기능과 최신 릴리스에 대한 정보를 받아보세요.",
"no": "Meld deg på OpenHands-bloggen for å høre om nye funksjoner og de nyeste utgivelsene.",
"it": "Iscriviti al Blog di OpenHands per conoscere le nuove funzionalità e gli ultimi rilasci.",
"pt": "Inscreva-se no Blog do OpenHands para ouvir sobre novos recursos e as últimas versões.",
"es": "Suscríbase al Blog de OpenHands para conocer las nuevas funciones y las últimas versiones.",
"ar": "اشترك في مدونة OpenHands للاطلاع على الميزات الجديدة وأحدث الإصدارات",
"fr": "Inscrivez-vous au blog OpenHands pour connaître les nouvelles fonctionnalités et les dernières versions.",
"tr": "Yeni özellikler ve en son sürümler hakkında bilgi almak için OpenHands Blog'a kaydolun.",
"de": "Melden Sie sich für den OpenHands Blog an, um über neue Funktionen und die neuesten Versionen informiert zu werden."
},
"TIPS$API_USAGE": {
"en": "OpenHands has an API! Create OpenHands conversations with simple cURL command.",
"ja": "OpenHandsにはAPIがあります!簡単なcURLコマンドでOpenHands会話を作成できます。",
"zh-CN": "OpenHands有API!使用简单的cURL命令创建OpenHands对话。",
"zh-TW": "OpenHands有API!使用簡單的cURL命令創建OpenHands對話。",
"ko-KR": "OpenHands에는 API가 있습니다! 간단한 cURL 명령으로 OpenHands 대화를 만들 수 있습니다.",
"no": "OpenHands har et API! Opprett OpenHands-samtaler med enkel cURL-kommando.",
"it": "OpenHands ha un'API! Crea conversazioni OpenHands con un semplice comando cURL.",
"pt": "OpenHands tem uma API! Crie conversas OpenHands com um simples comando cURL.",
"es": "¡OpenHands tiene una API! Cree conversaciones de OpenHands con un simple comando cURL.",
"ar": "OpenHands لديه واجهة برمجة تطبيقات! قم بإنشاء محادثات OpenHands باستخدام أمر cURL بسيط.",
"fr": "OpenHands a une API ! Créez des conversations OpenHands avec une simple commande cURL.",
"tr": "OpenHands'in bir API'si var! Basit bir cURL komutuyla OpenHands konuşmaları oluşturun.",
"de": "OpenHands hat eine API! Erstellen Sie OpenHands-Gespräche mit einem einfachen cURL-Befehl."
},
"TIPS$LEARN_MORE": {
"en": "Learn more",
"ja": "詳細を見る",
"zh-CN": "了解更多",
"zh-TW": "了解更多",
"ko-KR": "더 알아보기",
"no": "Lær mer",
"it": "Scopri di più",
"pt": "Saiba mais",
"es": "Más información",
"ar": "اعرف المزيد",
"fr": "En savoir plus",
"tr": "Daha fazla bilgi",
"de": "Mehr erfahren"
},
"TIPS$PROTIP": {
"en": "Protip",
"ja": "プロのヒント",
"zh-CN": "专业提示",
"zh-TW": "專業提示",
"ko-KR": "프로팁",
"no": "Proffetips",
"it": "Consiglio pro",
"pt": "Dica profissional",
"es": "Consejo profesional",
"ar": "نصيحة احترافية",
"fr": "Astuce pro",
"tr": "Uzman ipucu",
"de": "Profi-Tipp"
}
}
-2
View File
@@ -25,8 +25,6 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
provider_tokens_set: DEFAULT_SETTINGS.PROVIDER_TOKENS_SET,
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
enable_proactive_conversation_starters:
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
user_consents_to_analytics: DEFAULT_SETTINGS.USER_CONSENTS_TO_ANALYTICS,
};
+1 -1
View File
@@ -17,7 +17,7 @@ export default [
route("api-keys", "routes/api-keys.tsx"),
]),
route("conversations/:conversationId", "routes/conversation.tsx", [
index("routes/changes-tab.tsx"),
index("routes/editor.tsx"),
route("browser", "routes/browser-tab.tsx"),
route("jupyter", "routes/jupyter-tab.tsx"),
route("served", "routes/served-tab.tsx"),
+1 -34
View File
@@ -15,14 +15,12 @@ import {
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { AppSettingsInputsSkeleton } from "#/components/features/settings/app-settings/app-settings-inputs-skeleton";
import { useConfig } from "#/hooks/query/use-config";
function AppSettingsScreen() {
const { t } = useTranslation();
const { mutate: saveSettings, isPending } = useSaveSettings();
const { data: settings, isLoading } = useSettings();
const { data: config } = useConfig();
const [languageInputHasChanged, setLanguageInputHasChanged] =
React.useState(false);
@@ -32,10 +30,6 @@ function AppSettingsScreen() {
soundNotificationsSwitchHasChanged,
setSoundNotificationsSwitchHasChanged,
] = React.useState(false);
const [
proactiveConversationsSwitchHasChanged,
setProactiveConversationsSwitchHasChanged,
] = React.useState(false);
const formAction = (formData: FormData) => {
const languageLabel = formData.get("language-input")?.toString();
@@ -49,16 +43,11 @@ function AppSettingsScreen() {
const enableSoundNotifications =
formData.get("enable-sound-notifications-switch")?.toString() === "on";
const enableProactiveConversations =
formData.get("enable-proactive-conversations-switch")?.toString() ===
"on";
saveSettings(
{
LANGUAGE: language,
user_consents_to_analytics: enableAnalytics,
ENABLE_SOUND_NOTIFICATIONS: enableSoundNotifications,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: enableProactiveConversations,
},
{
onSuccess: () => {
@@ -101,19 +90,10 @@ function AppSettingsScreen() {
);
};
const checkIfProactiveConversationsSwitchHasChanged = (checked: boolean) => {
const currentProactiveConversations =
!!settings?.ENABLE_PROACTIVE_CONVERSATION_STARTERS;
setProactiveConversationsSwitchHasChanged(
checked !== currentProactiveConversations,
);
};
const formIsClean =
!languageInputHasChanged &&
!analyticsSwitchHasChanged &&
!soundNotificationsSwitchHasChanged &&
!proactiveConversationsSwitchHasChanged;
!soundNotificationsSwitchHasChanged;
const shouldBeLoading = !settings || isLoading || isPending;
@@ -149,19 +129,6 @@ function AppSettingsScreen() {
>
{t(I18nKey.SETTINGS$SOUND_NOTIFICATIONS)}
</SettingsSwitch>
{config?.APP_MODE === "saas" && (
<SettingsSwitch
testId="enable-proactive-conversations-switch"
name="enable-proactive-conversations-switch"
defaultIsToggled={
!!settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS
}
onToggle={checkIfProactiveConversationsSwitchHasChanged}
>
{t(I18nKey.SETTINGS$PROACTIVE_CONVERSATION_STARTERS)}
</SettingsSwitch>
)}
</div>
)}
+6 -15
View File
@@ -1,6 +1,6 @@
import { useDisclosure } from "@heroui/react";
import React from "react";
import { Outlet, useNavigate } from "react-router";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
import { useTranslation } from "react-i18next";
@@ -16,6 +16,7 @@ import { Controls } from "#/components/features/controls/controls";
import { clearMessages, addUserMessage } from "#/state/chat-slice";
import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import GlobeIcon from "#/icons/globe.svg?react";
import JupyterIcon from "#/icons/jupyter.svg?react";
import TerminalIcon from "#/icons/terminal.svg?react";
@@ -31,6 +32,7 @@ import {
ResizablePanel,
} from "#/components/layout/resizable-panel";
import Security from "#/components/shared/modals/security/security";
import { useEndSession } from "#/hooks/use-end-session";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { useSettings } from "#/hooks/query/use-settings";
@@ -39,7 +41,6 @@ import { RootState } from "#/store";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { TabContent } from "#/components/layout/tab-content";
function AppContent() {
useConversationConfig();
@@ -54,7 +55,7 @@ function AppContent() {
);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const navigate = useNavigate();
const endSession = useEndSession();
// Set the document title to the conversation title when available
useDocumentTitleFromState();
@@ -66,7 +67,7 @@ function AppContent() {
displayErrorToast(
"This conversation does not exist, or you do not have permission to access it.",
);
navigate("/");
endSession();
}
}, [conversation, isFetched]);
@@ -112,8 +113,6 @@ function AppContent() {
} = useDisclosure();
function renderMain() {
const basePath = `/conversations/${conversationId}`;
if (width <= 640) {
return (
<div className="rounded-xl overflow-hidden border border-neutral-600 w-full bg-base-secondary">
@@ -198,15 +197,7 @@ function AppContent() {
},
]}
>
{/* Use both Outlet and TabContent */}
<div className="h-full w-full">
{/* Keep the Outlet for React Router to work properly */}
<div className="hidden">
<Outlet />
</div>
{/* Use TabContent to keep all tabs loaded but only show the active one */}
<TabContent conversationPath={basePath} />
</div>
<Outlet />
</Container>
}
/>
@@ -1,13 +1,11 @@
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import React from "react";
import { FileDiffViewer } from "#/components/features/diff-viewer/file-diff-viewer";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { useGetGitChanges } from "#/hooks/query/use-get-git-changes";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { RandomTip } from "#/components/features/tips/random-tip";
// Error message patterns
const GIT_REPO_ERROR_PATTERN = /not a git repository/i;
@@ -20,7 +18,7 @@ function StatusMessage({ children }: React.PropsWithChildren) {
);
}
function GitChanges() {
function EditorScreen() {
const { t } = useTranslation();
const { data: gitChanges, isSuccess, isError, error } = useGetGitChanges();
@@ -30,50 +28,37 @@ function GitChanges() {
const isNotGitRepoError =
error && GIT_REPO_ERROR_PATTERN.test(retrieveAxiosErrorMessage(error));
let statusMessage: React.ReactNode = null;
if (!runtimeIsActive) {
statusMessage = <span>{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}</span>;
} else if (isNotGitRepoError) {
if (error) {
statusMessage = <span>{retrieveAxiosErrorMessage(error)}</span>;
} else {
statusMessage = (
<span>
return (
<main className="h-full overflow-y-scroll px-4 py-3 gap-3 flex flex-col">
{!runtimeIsActive && (
<StatusMessage>
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
</StatusMessage>
)}
{!isNotGitRepoError && error && (
<StatusMessage>{retrieveAxiosErrorMessage(error)}</StatusMessage>
)}
{isNotGitRepoError && (
<StatusMessage>
{t(I18nKey.DIFF_VIEWER$NOT_A_GIT_REPO)}
<br />
{t(I18nKey.DIFF_VIEWER$ASK_OH)}
</span>
);
}
}
</StatusMessage>
)}
return (
<main className="h-full overflow-y-scroll px-4 py-3 gap-3 flex flex-col items-center">
{!isSuccess || !gitChanges.length ? (
<div className="relative flex h-full w-full items-center">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2">
{statusMessage && <StatusMessage>{statusMessage}</StatusMessage>}
</div>
<div className="absolute inset-x-0 bottom-0">
{!isError && gitChanges?.length === 0 && (
<div className="max-w-2xl mb-4 text-m bg-tertiary rounded-xl p-4 text-left mx-auto">
<RandomTip />
</div>
)}
</div>
</div>
) : (
{runtimeIsActive && !isError && gitChanges?.length === 0 && (
<StatusMessage>{t(I18nKey.DIFF_VIEWER$NO_CHANGES)}</StatusMessage>
)}
{isSuccess &&
gitChanges.map((change) => (
<FileDiffViewer
key={change.path}
path={change.path}
type={change.status}
/>
))
)}
))}
</main>
);
}
export default GitChanges;
export default EditorScreen;
+1 -9
View File
@@ -22,7 +22,6 @@ import { useConfig } from "#/hooks/query/use-config";
import { isCustomModel } from "#/utils/is-custom-model";
import { LlmSettingsInputsSkeleton } from "#/components/features/settings/llm-settings/llm-settings-inputs-skeleton";
import { KeyStatusIcon } from "#/components/features/settings/key-status-icon";
import { DEFAULT_SETTINGS } from "#/services/settings";
function LlmSettingsScreen() {
const { t } = useTranslation();
@@ -102,13 +101,6 @@ function LlmSettingsScreen() {
{
LLM_MODEL: fullLlmModel,
llm_api_key: apiKey || null,
// reset advanced settings
LLM_BASE_URL: DEFAULT_SETTINGS.LLM_BASE_URL,
AGENT: DEFAULT_SETTINGS.AGENT,
CONFIRMATION_MODE: DEFAULT_SETTINGS.CONFIRMATION_MODE,
SECURITY_ANALYZER: DEFAULT_SETTINGS.SECURITY_ANALYZER,
ENABLE_DEFAULT_CONDENSER: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
},
{
onSuccess: handleSuccessfulMutation,
@@ -134,7 +126,7 @@ function LlmSettingsScreen() {
{
LLM_MODEL: model,
LLM_BASE_URL: baseUrl,
llm_api_key: apiKey || null,
llm_api_key: apiKey,
AGENT: agent,
CONFIRMATION_MODE: confirmationMode,
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
+16 -11
View File
@@ -5,6 +5,7 @@ import {
Outlet,
useNavigate,
useLocation,
useSearchParams,
} from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
@@ -59,8 +60,9 @@ export default function MainApp() {
const navigate = useNavigate();
const { pathname } = useLocation();
const tosPageStatus = useIsOnTosPage();
const [searchParams] = useSearchParams();
const { data: settings } = useSettings();
const { error } = useBalance();
const { error, isFetching } = useBalance();
const { migrateUserConsent } = useMigrateUserConsent();
const { t } = useTranslation();
@@ -111,19 +113,22 @@ export default function MainApp() {
}
}, [tosPageStatus]);
React.useEffect(() => {
if (settings?.IS_NEW_USER && config.data?.APP_MODE === "saas") {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
}
}, [settings?.IS_NEW_USER, config.data?.APP_MODE]);
React.useEffect(() => {
// Don't do any redirects when on TOS page
// Don't allow users to use the app if it 402s
if (!tosPageStatus && error?.status === 402 && pathname !== "/") {
navigate("/");
if (!tosPageStatus) {
// Don't allow users to use the app if it 402s
if (error?.status === 402 && pathname !== "/") {
navigate("/");
} else if (
!isFetching &&
searchParams.get("free_credits") === "success"
) {
displaySuccessToast(t(I18nKey.BILLING$YOURE_IN));
searchParams.delete("free_credits");
navigate("/");
}
}
}, [error?.status, pathname, tosPageStatus]);
}, [error?.status, pathname, isFetching, tosPageStatus]);
// When on TOS page, we don't make any API calls, so we need to handle this case
const userIsAuthed = tosPageStatus ? false : !!isAuthed && !authError;
+37 -8
View File
@@ -1,17 +1,48 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { useConversation } from "#/context/conversation-context";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
function VSCodeTab() {
const { t } = useTranslation();
const { data, isLoading, error } = useVSCodeUrl();
const { conversationId } = useConversation();
const [vsCodeUrl, setVsCodeUrl] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const iframeRef = React.useRef<HTMLIFrameElement>(null);
React.useEffect(() => {
async function fetchVSCodeUrl() {
if (!conversationId || isRuntimeInactive) return;
try {
setIsLoading(true);
const response = await fetch(
`/api/conversations/${conversationId}/vscode-url`,
);
const data = await response.json();
if (data.vscode_url) {
const transformedUrl = transformVSCodeUrl(data.vscode_url);
setVsCodeUrl(transformedUrl);
} else {
setError(t(I18nKey.VSCODE$URL_NOT_AVAILABLE));
}
} catch (err) {
setError(t(I18nKey.VSCODE$FETCH_ERROR));
// Error is handled by setting the error state
} finally {
setIsLoading(false);
}
}
fetchVSCodeUrl();
}, [conversationId, isRuntimeInactive, t]);
if (isRuntimeInactive) {
return (
@@ -29,10 +60,10 @@ function VSCodeTab() {
);
}
if (error || (data && data.error) || !data?.url) {
if (error || !vsCodeUrl) {
return (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{data?.error || String(error) || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
{error || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
</div>
);
}
@@ -40,9 +71,8 @@ function VSCodeTab() {
return (
<div className="h-full w-full">
<iframe
ref={iframeRef}
title={t(I18nKey.VSCODE$TITLE)}
src={data.url}
src={vsCodeUrl}
className="w-full h-full border-0"
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
/>
@@ -50,5 +80,4 @@ function VSCodeTab() {
);
}
// Export the VSCodeTab directly since we're using the provider at a higher level
export default VSCodeTab;
-1
View File
@@ -15,7 +15,6 @@ export const DEFAULT_SETTINGS: Settings = {
ENABLE_DEFAULT_CONDENSER: true,
ENABLE_SOUND_NOTIFICATIONS: false,
USER_CONSENTS_TO_ANALYTICS: false,
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
IS_NEW_USER: true,
};
-7
View File
@@ -15,13 +15,6 @@ interface GitUser {
email: string | null;
}
interface Branch {
name: string;
commit_sha: string;
protected: boolean;
last_push_date?: string;
}
interface GitRepository {
id: number;
full_name: string;
-2
View File
@@ -21,7 +21,6 @@ export type Settings = {
PROVIDER_TOKENS_SET: Partial<Record<Provider, string | null>>;
ENABLE_DEFAULT_CONDENSER: boolean;
ENABLE_SOUND_NOTIFICATIONS: boolean;
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
USER_CONSENTS_TO_ANALYTICS: boolean | null;
IS_NEW_USER?: boolean;
};
@@ -38,7 +37,6 @@ export type ApiSettings = {
remote_runtime_resource_factor: number | null;
enable_default_condenser: boolean;
enable_sound_notifications: boolean;
enable_proactive_conversation_starters: boolean;
user_consents_to_analytics: boolean | null;
provider_tokens_set: Partial<Record<Provider, string | null>>;
};
-48
View File
@@ -1,48 +0,0 @@
import { I18nKey } from "#/i18n/declaration";
export interface Tip {
key: I18nKey;
link?: string;
}
export const TIPS: Tip[] = [
{
key: I18nKey.TIPS$CUSTOMIZE_MICROAGENT,
link: "https://docs.all-hands.dev/modules/usage/prompting/microagents-repo",
},
{
key: I18nKey.TIPS$SETUP_SCRIPT,
link: "https://docs.all-hands.dev/modules/usage/customization/repository",
},
{ key: I18nKey.TIPS$VSCODE_INSTANCE },
{ key: I18nKey.TIPS$SAVE_WORK },
{
key: I18nKey.TIPS$SPECIFY_FILES,
link: "https://docs.all-hands.dev/modules/usage/prompting/prompting-best-practices",
},
{
key: I18nKey.TIPS$HEADLESS_MODE,
link: "https://docs.all-hands.dev/modules/usage/how-to/headless-mode",
},
{
key: I18nKey.TIPS$CLI_MODE,
link: "https://docs.all-hands.dev/modules/usage/how-to/cli-mode",
},
{
key: I18nKey.TIPS$GITHUB_HOOK,
link: "https://docs.all-hands.dev/modules/usage/cloud/cloud-github-resolver",
},
{
key: I18nKey.TIPS$BLOG_SIGNUP,
link: "https://www.all-hands.dev/blog",
},
{
key: I18nKey.TIPS$API_USAGE,
link: "https://docs.all-hands.dev/swagger-ui/",
},
];
export function getRandomTip(): Tip {
const randomIndex = Math.floor(Math.random() * TIPS.length);
return TIPS[randomIndex];
}
-17
View File
@@ -8,7 +8,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider, initReactI18next } from "react-i18next";
import i18n from "i18next";
import { vi } from "vitest";
import { AxiosError } from "axios";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { ConversationProvider } from "#/context/conversation-context";
@@ -84,19 +83,3 @@ export function renderWithProviders(
}
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
}
export const createAxiosNotFoundErrorObject = () =>
new AxiosError(
"Request failed with status code 404",
"ERR_BAD_REQUEST",
undefined,
undefined,
{
status: 404,
statusText: "Not Found",
data: { message: "Settings not found" },
headers: {},
// @ts-expect-error - we only need the response object for this test
config: {},
},
);
+5 -35
View File
@@ -16,51 +16,21 @@ ALWAYS use the GitHub API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitHub (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://${GITHUB_TOKEN}@github.com/username/repo.git`
## IMPORTANT: ALWAYS USE THE MCP TOOL FOR CREATING PULL REQUESTS
When creating pull requests, ALWAYS use the MCP (Model Context Protocol) tool instead of directly using the GitHub API. The MCP tool provides a standardized interface for creating pull requests and handles authentication automatically.
To create a pull request using the MCP tool:
1. Push your changes to a branch
2. Use the MCP `create_github_pr` tool to create the pull request
Example of using the MCP tool to create a pull request:
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_github_pr",
"arguments": {
"repository": "owner/repo",
"title": "Your PR title",
"body": "Description of your changes",
"head": "your-feature-branch",
"base": "main",
"draft": true
}
}
}
```
The MCP server will handle authentication and create the pull request using the appropriate GitHub token from the user's settings.
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Use the GitHub API to create a pull request, if you haven't already
* Once you've created your own branch or a pull request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a pull request, send the user a short message with a link to the pull request.
* Prefer "Draft" pull requests when possible
* Do NOT mark a pull request as ready to review unless the user explicitly says so
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands and then using the MCP tool:
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
# Then use the MCP tool to create the PR instead of directly using the GitHub API
curl -X POST "https://api.github.com/repos/$ORG_NAME/$REPO_NAME/pulls" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-d '{"title":"Create widget","head":"create-widget","base":"openhands-workspace"}'
```
IMPORTANT: NEVER use the GitHub API directly to create pull requests. ALWAYS use the MCP tool.
+6 -36
View File
@@ -16,50 +16,20 @@ ALWAYS use the GitLab API for operations instead of a web browser.
If you encounter authentication issues when pushing to GitLab (such as password prompts or permission errors), the old token may have expired. In such case, update the remote URL to include the current token: `git remote set-url origin https://oauth2:${GITLAB_TOKEN}@gitlab.com/username/repo.git`
## IMPORTANT: ALWAYS USE THE MCP TOOL FOR CREATING MERGE REQUESTS
When creating merge requests, ALWAYS use the MCP (Model Context Protocol) tool instead of directly using the GitLab API. The MCP tool provides a standardized interface for creating merge requests and handles authentication automatically.
To create a merge request using the MCP tool:
1. Push your changes to a branch
2. Use the MCP `create_gitlab_mr` tool to create the merge request
Example of using the MCP tool to create a merge request:
```json
{
"jsonrpc": "2.0",
"id": "1",
"method": "callTool",
"params": {
"name": "create_gitlab_mr",
"arguments": {
"project_id": "group/project",
"title": "Your MR title",
"description": "Description of your changes",
"source_branch": "your-feature-branch",
"target_branch": "main",
"draft": true
}
}
}
```
The MCP server will handle authentication and create the merge request using the appropriate GitLab token from the user's settings.
Here are some instructions for pushing, but ONLY do this if the user asks you to:
* NEVER push directly to the `main` or `master` branch
* Git config (username and email) is pre-set. Do not modify.
* You may already be on a branch starting with `openhands-workspace`. Create a new branch with a better name before pushing.
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the MR title and description as necessary, but don't change the branch name.
* Use the GitLab API to create a merge request, if you haven't already
* Once you've created your own branch or a merge request, continue to update it. Do NOT create a new one unless you are explicitly asked to. Update the PR title and description as necessary, but don't change the branch name.
* Use the main branch as the base branch, unless the user requests otherwise
* After opening or updating a merge request, send the user a short message with a link to the merge request.
* Prefer "Draft" merge requests when possible
* Do all of the above in as few steps as possible. E.g. you could open an MR with one step by running the following bash commands and then using the MCP tool:
* Do all of the above in as few steps as possible. E.g. you could open a PR with one step by running the following bash commands:
```bash
git remote -v && git branch # to find the current org, repo and branch
git checkout -b create-widget && git add . && git commit -m "Create widget" && git push -u origin create-widget
# Then use the MCP tool to create the MR instead of directly using the GitLab API
curl -X POST "https://gitlab.com/api/v4/projects/$PROJECT_ID/merge_requests" \
-H "Authorization: Bearer $GITLAB_TOKEN" \
-d '{"source_branch": "create-widget", "target_branch": "openhands-workspace", "title": "Create widget"}'
```
IMPORTANT: NEVER use the GitLab API directly to create merge requests. ALWAYS use the MCP tool.
@@ -162,7 +162,7 @@ class BrowsingAgent(Agent):
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
return AgentFinishAction(final_thought=event.content)
elif isinstance(event, Observation):
last_obs = event
@@ -201,10 +201,8 @@ class BrowsingAgent(Agent):
)
return MessageAction('Error encountered when browsing.')
goal, _ = state.get_current_user_intent()
if goal is None:
goal = state.inputs['task']
user_message_action = state.get_current_user_intent()
goal = user_message_action.content
system_msg = get_system_message(
goal,
@@ -1,13 +1,8 @@
import copy
import os
from collections import deque
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from litellm import ChatCompletionToolParam
from openhands.events.action import Action
from openhands.llm.llm import ModelResponse
from litellm import ChatCompletionToolParam
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
@@ -25,7 +20,7 @@ from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import Message
from openhands.events.action import AgentFinishAction, MessageAction
from openhands.events.action import Action, AgentFinishAction, MessageAction
from openhands.events.event import Event
from openhands.llm.llm import LLM
from openhands.memory.condenser import Condenser
@@ -80,26 +75,23 @@ class CodeActAgent(Agent):
- config (AgentConfig): The configuration for this agent
"""
super().__init__(llm, config)
self.pending_actions: deque['Action'] = deque()
self.pending_actions: deque[Action] = deque()
self.reset()
self.tools = self._get_tools()
self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
# Create a ConversationMemory instance
self.conversation_memory = ConversationMemory(self.config, self.prompt_manager)
self.condenser = Condenser.from_config(self.config.condenser)
logger.debug(f'Using condenser: {type(self.condenser)}')
@property
def prompt_manager(self) -> PromptManager:
if self._prompt_manager is None:
self._prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
self.response_to_actions_fn = codeact_function_calling.response_to_actions
return self._prompt_manager
def _get_tools(self) -> list['ChatCompletionToolParam']:
def _get_tools(self) -> list[ChatCompletionToolParam]:
# For these models, we use short tool descriptions ( < 1024 tokens)
# to avoid hitting the OpenAI token limit for tool descriptions.
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
@@ -138,7 +130,7 @@ class CodeActAgent(Agent):
super().reset()
self.pending_actions.clear()
def step(self, state: State) -> 'Action':
def step(self, state: State) -> Action:
"""Performs one step using the CodeAct Agent.
This includes gathering info on previous steps and prompting the model to make a command to execute.
@@ -206,7 +198,9 @@ class CodeActAgent(Agent):
params['extra_body'] = {'metadata': state.to_llm_metadata(agent_name=self.name)}
response = self.llm.completion(**params)
logger.debug(f'Response from LLM: {response}')
actions = self.response_to_actions(response)
actions = self.response_to_actions_fn(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
logger.debug(f'Actions after response_to_actions: {actions}')
for action in actions:
self.pending_actions.append(action)
@@ -280,8 +274,3 @@ class CodeActAgent(Agent):
self.conversation_memory.apply_prompt_caching(messages)
return messages
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
return codeact_function_calling.response_to_actions(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
@@ -105,7 +105,8 @@ def response_to_actions(
elif tool_call.function.name == 'delegate_to_browsing_agent':
action = AgentDelegateAction(
agent='BrowsingAgent',
inputs=arguments,
prompt=arguments.get('prompt', ''),
inputs={},
)
# ================================================
@@ -113,8 +114,10 @@ def response_to_actions(
# ================================================
elif tool_call.function.name == FinishTool['function']['name']:
action = AgentFinishAction(
final_thought=arguments.get('message', ''),
outputs=arguments.get('outputs', {}),
thought=arguments.get('thought', ''),
task_completed=arguments.get('task_completed', None),
final_thought=arguments.get('final_thought', ''),
)
# ================================================
@@ -3,13 +3,6 @@ ReadOnlyAgent - A specialized version of CodeActAgent that only uses read-only t
"""
import os
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from litellm import ChatCompletionToolParam
from openhands.events.action import Action
from openhands.llm.llm import ModelResponse
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.agenthub.readonly_agent import (
@@ -48,27 +41,24 @@ class ReadOnlyAgent(CodeActAgent):
- llm (LLM): The llm to be used by this agent
- config (AgentConfig): The configuration for this agent
"""
# Initialize the CodeActAgent class; some of it is overridden with class methods
# Initialize the CodeActAgent class but we'll override some of its behavior
super().__init__(llm, config)
# Override the tools to only include read-only tools
# Get the read-only tools from our own function_calling module
self.tools = readonly_function_calling.get_tools()
# Set up our own prompt manager
self.prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
self.response_to_actions_fn = readonly_function_calling.response_to_actions
logger.debug(
f"TOOLS loaded for ReadOnlyAgent: {', '.join([tool.get('function').get('name') for tool in self.tools])}"
)
@property
def prompt_manager(self) -> PromptManager:
# Set up our own prompt manager
if self._prompt_manager is None:
self._prompt_manager = PromptManager(
prompt_dir=os.path.join(os.path.dirname(__file__), 'prompts'),
)
return self._prompt_manager
def _get_tools(self) -> list['ChatCompletionToolParam']:
# Override the tools to only include read-only tools
# Get the read-only tools from our own function_calling module
return readonly_function_calling.get_tools()
def set_mcp_tools(self, mcp_tools: list[dict]) -> None:
"""Sets the list of MCP tools for the agent.
@@ -78,8 +68,3 @@ class ReadOnlyAgent(CodeActAgent):
logger.warning(
'ReadOnlyAgent does not support MCP tools. MCP tools will be ignored by the agent.'
)
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
return readonly_function_calling.response_to_actions(
response, mcp_tool_names=list(self.mcp_tools.keys())
)
@@ -216,7 +216,7 @@ Note:
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
return AgentFinishAction(final_thought=event.content)
elif isinstance(event, Observation):
# Only process BrowserOutputObservation and skip other observation types
if not isinstance(event, BrowserOutputObservation):
@@ -271,10 +271,10 @@ Note:
)
return MessageAction('Error encountered when browsing.')
set_of_marks = last_obs.set_of_marks
goal, image_urls = state.get_current_user_intent()
user_message_action = state.get_current_user_intent()
goal = user_message_action.content
image_urls = user_message_action.image_urls
if goal is None:
goal = state.inputs['task']
goal_txt, goal_images = create_goal_prompt(goal, image_urls)
observation_txt, som_screenshot = create_observation_prompt(
cur_axtree_txt, tabs, focused_element, error_prefix, set_of_marks
+8 -12
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Type
if TYPE_CHECKING:
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.events.action import Action
from openhands.events.action.message import SystemMessageAction
from openhands.utils.prompt import PromptManager
from litellm import ChatCompletionToolParam
from openhands.core.exceptions import (
@@ -20,6 +19,9 @@ from openhands.events.event import EventSource
from openhands.llm.llm import LLM
from openhands.runtime.plugins import PluginRequirement
if TYPE_CHECKING:
from openhands.utils.prompt import PromptManager
class Agent(ABC):
DEPRECATED = False
@@ -30,7 +32,7 @@ class Agent(ABC):
It tracks the execution status and maintains a history of interactions.
"""
_registry: dict[str, type['Agent']] = {}
_registry: dict[str, Type['Agent']] = {}
sandbox_plugins: list[PluginRequirement] = []
def __init__(
@@ -41,16 +43,10 @@ class Agent(ABC):
self.llm = llm
self.config = config
self._complete = False
self._prompt_manager: 'PromptManager' | None = None
self.prompt_manager: 'PromptManager' | None = None
self.mcp_tools: dict[str, ChatCompletionToolParam] = {}
self.tools: list = []
@property
def prompt_manager(self) -> 'PromptManager':
if self._prompt_manager is None:
raise ValueError(f'Prompt manager not initialized for agent {self.name}')
return self._prompt_manager
def get_system_message(self) -> 'SystemMessageAction | None':
"""
Returns a SystemMessageAction containing the system message and tools.
@@ -118,7 +114,7 @@ class Agent(ABC):
return self.__class__.__name__
@classmethod
def register(cls, name: str, agent_cls: type['Agent']) -> None:
def register(cls, name: str, agent_cls: Type['Agent']) -> None:
"""Registers an agent class in the registry.
Parameters:
@@ -133,7 +129,7 @@ class Agent(ABC):
cls._registry[name] = agent_cls
@classmethod
def get_cls(cls, name: str) -> type['Agent']:
def get_cls(cls, name: str) -> Type['Agent']:
"""Retrieves an agent class from the registry.
Parameters:
+16 -27
View File
@@ -5,7 +5,7 @@ import copy
import os
import time
import traceback
from typing import Callable, ClassVar
from typing import Callable, ClassVar, Tuple, Type
import litellm # noqa
from litellm.exceptions import ( # noqa
@@ -91,7 +91,7 @@ class AgentController:
agent_configs: dict[str, AgentConfig]
parent: 'AgentController | None' = None
delegate: 'AgentController | None' = None
_pending_action_info: tuple[Action, float] | None = None # (action, timestamp)
_pending_action_info: Tuple[Action, float] | None = None # (action, timestamp)
_closed: bool = False
filter_out: ClassVar[tuple[type[Event], ...]] = (
NullAction,
@@ -438,12 +438,13 @@ class AgentController:
elif isinstance(action, AgentDelegateAction):
await self.start_delegate(action)
assert self.delegate is not None
# Post a MessageAction with the task for the delegate
if 'task' in action.inputs:
# Post a MessageAction with the prompt for the delegate
if action.prompt:
self.event_stream.add_event(
MessageAction(content='TASK: ' + action.inputs['task']),
EventSource.USER,
MessageAction(content=action.prompt),
EventSource.USER, # Source is USER, as it represents the task prompt for the delegate
)
# Delegate starts in RUNNING state as it receives the prompt immediately
await self.delegate.set_agent_state_to(AgentState.RUNNING)
return
@@ -675,7 +676,7 @@ class AgentController:
Args:
action (AgentDelegateAction): The action containing information about the delegate agent to start.
"""
agent_cls: type[Agent] = Agent.get_cls(action.agent)
agent_cls: Type[Agent] = Agent.get_cls(action.agent)
agent_config = self.agent_configs.get(action.agent, self.agent.config)
llm_config = self.agent_to_llm_config.get(action.agent, self.agent.llm.config)
llm = LLM(config=llm_config, retry_listener=self._notify_on_llm_retry)
@@ -727,34 +728,22 @@ class AgentController:
# close the delegate controller before adding new events
asyncio.get_event_loop().run_until_complete(self.delegate.close())
if delegate_state in (AgentState.FINISHED, AgentState.REJECTED):
# retrieve delegate result
delegate_outputs = (
self.delegate.state.outputs if self.delegate.state else {}
)
# prepare delegate result observation
delegate_outputs = self.delegate.state.outputs if self.delegate.state else {}
formatted_output = ', '.join(
f'{key}: {value}' for key, value in delegate_outputs.items()
)
# prepare delegate result observation
# TODO: replace this with AI-generated summary (#2395)
formatted_output = ', '.join(
f'{key}: {value}' for key, value in delegate_outputs.items()
)
if delegate_state in (AgentState.FINISHED, AgentState.REJECTED):
content = (
f'{self.delegate.agent.name} finishes task with {formatted_output}'
)
else:
# delegate state is ERROR
# emit AgentDelegateObservation with error content
delegate_outputs = (
self.delegate.state.outputs if self.delegate.state else {}
)
content = (
f'{self.delegate.agent.name} encountered an error during execution.'
)
content = f'Delegated agent finished with result:\n\n{content}'
content = f'{self.delegate.agent.name} encountered an error during execution. Known results: {delegate_outputs}'
# emit the delegate result observation
obs = AgentDelegateObservation(outputs=delegate_outputs, content=content)
obs = AgentDelegateObservation(content=content, outputs={})
# associate the delegate action with the initiating tool call
for event in reversed(self.state.history):
+32 -12
View File
@@ -188,19 +188,39 @@ class State:
if not hasattr(self, 'history'):
self.history = []
def get_current_user_intent(self) -> tuple[str | None, list[str] | None]:
"""Returns the latest user message and image(if provided) that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
last_user_message = None
last_user_message_image_urls: list[str] | None = []
for event in reversed(self.view):
if isinstance(event, MessageAction) and event.source == 'user':
last_user_message = event.content
last_user_message_image_urls = event.image_urls
elif isinstance(event, AgentFinishAction):
if last_user_message is not None:
return last_user_message, None
def get_current_user_intent(self) -> MessageAction:
"""Returns the latest user MessageAction that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
likely_task: MessageAction | None = None
return last_user_message, last_user_message_image_urls
# Search in the view for the latest user message after the last finish action
for event in reversed(self.view):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
likely_task = event
elif isinstance(event, AgentFinishAction):
# If a FinishAction is found, the user message after it is the one we just found (if any)
break
# If a user message was found in the view after the last finish action, return it
if likely_task is not None:
return likely_task
# If no user message was found in the view after the last finish action,
# it means either there were no user messages in the view, or the last event in the view was a FinishAction
# In this case, we fall back to finding the very first user message in the full history.
logger.warning(
'No user message found in the view after the last FinishAction. Returning the first message in history.'
)
if self.history:
# Look for the very first user message in the full history
for event in self.history:
if (
isinstance(event, MessageAction)
and event.source == EventSource.USER
):
return event
# If no user message is found in the entire history, raise an error
raise ValueError('No user message found in history. This should not happen.')
def get_last_agent_message(self) -> MessageAction | None:
for event in reversed(self.view):
@@ -6,11 +6,13 @@ from uuid import uuid4
from prompt_toolkit.shortcuts import clear
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands.cli.commands import (
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.cli_commands import (
check_folder_security_agreement,
handle_commands,
)
from openhands.cli.tui import (
from openhands.core.cli_tui import (
UsageMetrics,
display_agent_running_message,
display_banner,
@@ -23,11 +25,9 @@ from openhands.cli.tui import (
read_confirmation_input,
read_prompt_input,
)
from openhands.cli.utils import (
from openhands.core.cli_utils import (
update_usage_metrics,
)
from openhands.controller import AgentController
from openhands.controller.agent import Agent
from openhands.core.config import (
AppConfig,
parse_arguments,
@@ -5,12 +5,12 @@ from prompt_toolkit import print_formatted_text
from prompt_toolkit.shortcuts import clear, print_container
from prompt_toolkit.widgets import Frame, TextArea
from openhands.cli.settings import (
from openhands.core.cli_settings import (
display_settings,
modify_llm_settings_advanced,
modify_llm_settings_basic,
)
from openhands.cli.tui import (
from openhands.core.cli_tui import (
COLOR_GREY,
UsageMetrics,
cli_confirm,
@@ -18,7 +18,7 @@ from openhands.cli.tui import (
display_shutdown_message,
display_status,
)
from openhands.cli.utils import (
from openhands.core.cli_utils import (
add_local_config_trusted_dir,
get_local_config_trusted_dirs,
read_file,
@@ -5,19 +5,19 @@ from prompt_toolkit.shortcuts import print_container
from prompt_toolkit.widgets import Frame, TextArea
from pydantic import SecretStr
from openhands.cli.tui import (
from openhands.controller.agent import Agent
from openhands.core.cli_tui import (
COLOR_GREY,
UserCancelledError,
cli_confirm,
kb_cancel,
)
from openhands.cli.utils import (
from openhands.core.cli_utils import (
VERIFIED_ANTHROPIC_MODELS,
VERIFIED_OPENAI_MODELS,
VERIFIED_PROVIDERS,
organize_models_and_providers,
)
from openhands.controller.agent import Agent
from openhands.core.config import AppConfig
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.utils import OH_DEFAULT_AGENT
@@ -4,7 +4,6 @@
import asyncio
import sys
import threading
import time
from prompt_toolkit import PromptSession, print_formatted_text
@@ -59,14 +58,12 @@ COMMANDS = {
'/exit': 'Exit the application',
'/help': 'Display available commands',
'/init': 'Initialize a new repository',
'/status': 'Display conversation details and usage metrics',
'/new': 'Create a new conversation',
'/status': 'Display session details and usage metrics',
'/new': 'Create a new session',
'/settings': 'Display and modify current settings',
'/resume': 'Resume the agent when paused',
}
print_lock = threading.Lock()
class UsageMetrics:
def __init__(self):
@@ -139,7 +136,7 @@ def display_banner(session_id: str):
print_formatted_text(HTML(f'<grey>OpenHands CLI v{__version__}</grey>'))
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Initialized conversation {session_id}</grey>'))
print_formatted_text(HTML(f'<grey>Initialized session {session_id}</grey>'))
print_formatted_text('')
@@ -167,28 +164,28 @@ def display_initial_user_prompt(prompt: str):
# Prompt output display functions
def display_event(event: Event, config: AppConfig) -> None:
with print_lock:
if isinstance(event, Action):
if hasattr(event, 'thought'):
display_message(event.thought)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
display_message(event.content)
if isinstance(event, CmdRunAction):
display_command(event)
if isinstance(event, CmdOutputObservation):
display_command_output(event.content)
if isinstance(event, FileEditAction):
display_file_edit(event)
if isinstance(event, FileEditObservation):
display_file_edit(event)
if isinstance(event, FileReadObservation):
display_file_read(event)
if isinstance(event, AgentStateChangedObservation):
display_agent_paused_message(event.agent_state)
if isinstance(event, Action):
if hasattr(event, 'thought'):
display_message(event.thought)
if isinstance(event, MessageAction):
if event.source == EventSource.AGENT:
display_message(event.content)
if isinstance(event, CmdRunAction):
display_command(event)
if isinstance(event, CmdOutputObservation):
display_command_output(event.content)
if isinstance(event, FileEditAction):
display_file_edit(event)
if isinstance(event, FileEditObservation):
display_file_edit(event)
if isinstance(event, FileReadObservation):
display_file_read(event)
if isinstance(event, AgentStateChangedObservation):
display_agent_paused_message(event.agent_state)
def display_message(message: str):
time.sleep(0.2)
message = message.strip()
if message:
@@ -251,7 +248,6 @@ def display_file_edit(event: FileEditAction | FileEditObservation):
title='File Edit',
style=f'fg:{COLOR_GREY}',
)
print_formatted_text('')
print_container(container)
@@ -266,7 +262,6 @@ def display_file_read(event: FileReadObservation):
title='File Read',
style=f'fg:{COLOR_GREY}',
)
print_formatted_text('')
print_container(container)
@@ -379,13 +374,13 @@ def get_session_duration(session_init_time: float) -> str:
def display_shutdown_message(usage_metrics: UsageMetrics, session_id: str):
duration_str = get_session_duration(usage_metrics.session_init_time)
print_formatted_text(HTML('<grey>Closing current conversation...</grey>'))
print_formatted_text(HTML('<grey>Closing current session...</grey>'))
print_formatted_text('')
display_usage_metrics(usage_metrics)
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Conversation duration: {duration_str}</grey>'))
print_formatted_text(HTML(f'<grey>Session duration: {duration_str}</grey>'))
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Closed conversation {session_id}</grey>'))
print_formatted_text(HTML(f'<grey>Closed session {session_id}</grey>'))
print_formatted_text('')
@@ -393,8 +388,8 @@ def display_status(usage_metrics: UsageMetrics, session_id: str):
duration_str = get_session_duration(usage_metrics.session_init_time)
print_formatted_text('')
print_formatted_text(HTML(f'<grey>Conversation ID: {session_id}</grey>'))
print_formatted_text(HTML(f'<grey>Uptime: {duration_str}</grey>'))
print_formatted_text(HTML(f'<grey>Session ID: {session_id}</grey>'))
print_formatted_text(HTML(f'<grey>Uptime: {duration_str}</grey>'))
print_formatted_text('')
display_usage_metrics(usage_metrics)
@@ -1,15 +1,16 @@
from pathlib import Path
from typing import Dict, List
import toml
from openhands.cli.tui import (
from openhands.core.cli_tui import (
UsageMetrics,
)
from openhands.events.event import Event
from openhands.llm.metrics import Metrics
_LOCAL_CONFIG_FILE_PATH = Path.home() / '.openhands' / 'config.toml'
_DEFAULT_CONFIG: dict[str, dict[str, list[str]]] = {'sandbox': {'trusted_dirs': []}}
_DEFAULT_CONFIG: Dict[str, Dict[str, List[str]]] = {'sandbox': {'trusted_dirs': []}}
def get_local_config_trusted_dirs() -> list[str]:
-3
View File
@@ -39,8 +39,6 @@ class SandboxConfig(BaseModel):
docker_runtime_kwargs: Additional keyword arguments to pass to the Docker runtime when running containers.
This should be a JSON string that will be parsed into a dictionary.
trusted_dirs: List of directories that can be trusted to run the OpenHands CLI.
vscode_port: The port to use for VSCode. If None, a random port will be chosen.
This is useful when deploying OpenHands in a remote machine where you need to expose a specific port.
"""
remote_runtime_api_url: str | None = Field(default='http://localhost:8000')
@@ -79,7 +77,6 @@ class SandboxConfig(BaseModel):
docker_runtime_kwargs: dict | None = Field(default=None)
selected_repo: str | None = Field(default=None)
trusted_dirs: list[str] = Field(default_factory=list)
vscode_port: int | None = Field(default=None)
model_config = {'extra': 'forbid'}
+3 -5
View File
@@ -1,7 +1,7 @@
import hashlib
import os
import uuid
from typing import Callable
from typing import Callable, Tuple, Type
from pydantic import SecretStr
@@ -126,8 +126,6 @@ def initialize_repository_for_runtime(
)
# Run setup script if it exists
runtime.maybe_run_setup_script()
# Set up git hooks if pre-commit.sh exists
runtime.maybe_setup_git_hooks()
return repo_directory
@@ -173,7 +171,7 @@ def create_memory(
def create_agent(config: AppConfig) -> Agent:
agent_cls: type[Agent] = Agent.get_cls(config.default_agent)
agent_cls: Type[Agent] = Agent.get_cls(config.default_agent)
agent_config = config.get_agent_config(config.default_agent)
llm_config = config.get_llm_config_from_agent(config.default_agent)
@@ -191,7 +189,7 @@ def create_controller(
config: AppConfig,
headless_mode: bool = True,
replay_events: list[Event] | None = None,
) -> tuple[AgentController, State | None]:
) -> Tuple[AgentController, State | None]:
event_stream = runtime.event_stream
initial_state = None
try:
+7
View File
@@ -86,6 +86,13 @@ class AgentRejectAction(Action):
class AgentDelegateAction(Action):
agent: str
inputs: dict
"""Deprecated.
Delegate agents run similarly to the main agent:
- start from a prompt (passed in the 'prompt' field)
- end with an AgentFinishAction.
"""
prompt: str
"""The prompt/task for the delegate agent"""
thought: str = ''
action: str = ActionType.DELEGATE
+7 -2
View File
@@ -10,13 +10,18 @@ class AgentDelegateObservation(Observation):
Attributes:
content (str): The content of the observation.
outputs (dict): The outputs of the delegated agent.
outputs (dict): The outputs of the delegated agent. (deprecated)
observation (str): The type of observation.
"""
outputs: dict
"""Deprecated.
Delegate agents run similarly to the main agent:
- start from a prompt (passed in the 'prompt' field)
- end with an AgentFinishAction.
"""
observation: str = ObservationType.DELEGATE
@property
def message(self) -> str:
return ''
return self.content
+48 -91
View File
@@ -6,14 +6,8 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.queries import (
suggested_task_issue_graphql_query,
suggested_task_pr_graphql_query,
)
from openhands.integrations.service_types import (
BaseGitService,
Branch,
GitService,
ProviderType,
Repository,
@@ -50,9 +44,6 @@ class GitHubService(BaseGitService, GitService):
if base_domain:
self.BASE_URL = f'https://{base_domain}/api/v3'
self.external_auth_id = external_auth_id
self.external_auth_token = external_auth_token
@property
def provider(self) -> str:
return ProviderType.GITHUB.value
@@ -293,23 +284,60 @@ class GitHubService(BaseGitService, GitService):
Returns:
- PRs authored by the user.
- Issues assigned to the user.
Note: Queries are split to avoid timeout issues.
"""
# Get user info to use in queries
user = await self.get_user()
login = user.login
tasks: list[SuggestedTask] = []
query = """
query GetUserTasks($login: String!) {
user(login: $login) {
pullRequests(first: 100, states: [OPEN], orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
mergeable
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
state
}
}
}
}
reviews(first: 100, states: [CHANGES_REQUESTED, COMMENTED]) {
nodes {
state
}
}
}
}
issues(first: 100, states: [OPEN], filterBy: {assignee: $login}, orderBy: {field: UPDATED_AT, direction: DESC}) {
nodes {
number
title
repository {
nameWithOwner
}
}
}
}
}
"""
variables = {'login': login}
try:
pr_response = await self.execute_graphql_query(
suggested_task_pr_graphql_query, variables
)
pr_data = pr_response['data']['user']
response = await self.execute_graphql_query(query, variables)
data = response['data']['user']
tasks: list[SuggestedTask] = []
# Process pull requests
for pr in pr_data['pullRequests']['nodes']:
for pr in data['pullRequests']['nodes']:
repo_name = pr['repository']['nameWithOwner']
# Start with default task type
@@ -345,24 +373,8 @@ class GitHubService(BaseGitService, GitService):
)
)
except Exception as e:
logger.info(
f'Error fetching suggested task for PRs: {e}',
extra={
'signal': 'github_suggested_tasks',
'user_id': self.external_auth_id,
},
)
try:
# Execute issue query
issue_response = await self.execute_graphql_query(
suggested_task_issue_graphql_query, variables
)
issue_data = issue_response['data']['user']
# Process issues
for issue in issue_data['issues']['nodes']:
for issue in data['issues']['nodes']:
repo_name = issue['repository']['nameWithOwner']
tasks.append(
SuggestedTask(
@@ -375,17 +387,8 @@ class GitHubService(BaseGitService, GitService):
)
return tasks
except Exception as e:
logger.info(
f'Error fetching suggested task for issues: {e}',
extra={
'signal': 'github_suggested_tasks',
'user_id': self.external_auth_id,
},
)
return tasks
except Exception:
return []
async def get_repository_details_from_repo_name(
self, repository: str
@@ -401,52 +404,6 @@ class GitHubService(BaseGitService, GitService):
is_public=not repo.get('private', True),
)
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository"""
url = f'{self.BASE_URL}/repos/{repository}/branches'
# Set maximum branches to fetch (10 pages with 100 per page)
MAX_BRANCHES = 1000
PER_PAGE = 100
all_branches: list[Branch] = []
page = 1
# Fetch up to 10 pages of branches
while page <= 10 and len(all_branches) < MAX_BRANCHES:
params = {'per_page': str(PER_PAGE), 'page': str(page)}
response, headers = await self._make_request(url, params)
if not response: # No more branches
break
for branch_data in response:
# Extract the last commit date if available
last_push_date = None
if branch_data.get('commit') and branch_data['commit'].get('commit'):
commit_info = branch_data['commit']['commit']
if commit_info.get('committer') and commit_info['committer'].get(
'date'
):
last_push_date = commit_info['committer']['date']
branch = Branch(
name=branch_data.get('name'),
commit_sha=branch_data.get('commit', {}).get('sha', ''),
protected=branch_data.get('protected', False),
last_push_date=last_push_date,
)
all_branches.append(branch)
page += 1
# Check if we've reached the last page
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
return all_branches
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',
@@ -196,6 +196,7 @@ class GitLabService(BaseGitService, GitService):
full_name=repo.get('path_with_namespace'),
stargazers_count=repo.get('star_count'),
git_provider=ProviderType.GITLAB,
is_public=True,
)
for repo in response
]
+12 -5
View File
@@ -31,6 +31,7 @@ from openhands.server.types import AppMode
class ProviderToken(BaseModel):
token: SecretStr | None = Field(default=None)
user_id: str | None = Field(default=None)
host: str | None = Field(default=None)
model_config = {
'frozen': True, # Makes the entire model immutable
@@ -40,15 +41,20 @@ class ProviderToken(BaseModel):
@classmethod
def from_value(cls, token_value: ProviderToken | dict[str, str]) -> ProviderToken:
"""Factory method to create a ProviderToken from various input types"""
if isinstance(token_value, ProviderToken):
if isinstance(token_value, cls):
return token_value
elif isinstance(token_value, dict):
token_str = token_value.get('token')
token_str = token_value.get('token', '')
# Override with emtpy string if it was set to None
# Cannot pass None to SecretStr
if token_str is None:
token_str = ''
user_id = token_value.get('user_id')
return cls(token=SecretStr(token_str), user_id=user_id)
host = token_value.get('host')
return cls(token=SecretStr(token_str), user_id=user_id, host=host)
else:
raise ValueError('Unsupport Provider token type')
raise ValueError('Unsupported Provider token type')
PROVIDER_TOKEN_TYPE = MappingProxyType[ProviderType, ProviderToken]
@@ -166,7 +172,8 @@ class ProviderHandler:
query, per_page, sort, order
)
all_repos.extend(service_repos)
except Exception:
except Exception as e:
logger.warning(f'Error searching repos from {provider}: {e}')
continue
return all_repos

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