Compare commits

..

99 Commits

Author SHA1 Message Date
openhands
1b206c9727 Fix unlocalized strings in microagent components 2025-06-30 15:18:48 +00:00
amanape
b100bb51c9 merge 2025-06-30 19:00:58 +04:00
Hiep Le
ec03ce1ca0 feat(frontend): Tooltip for "suggested tasks" (#9447) 2025-06-30 14:46:39 +00:00
Hiep Le
46157a85d8 fix(frontend): Response issue - the content of the “Agent Tools & Metadata” modal is overflow. (#9449) 2025-06-30 14:44:04 +00:00
Hiep Le
a691e3148a fix(frontend): Responsive issue - the horizontal scrollbar is showing when resizing the browser window (#9446) 2025-06-30 18:40:17 +04:00
Hiep Le
4674e0b77a refactor(frontend): When users hover over the buttons, the pointer will not be displayed (#9442) 2025-06-30 13:54:29 +00:00
dependabot[bot]
d7d0329d25 chore(deps): bump node from 22.16.0-bookworm-slim to 24.2.0-bookworm-slim in /containers/app (#9040)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-30 17:40:14 +04:00
Graham Neubig
17853cd5bd Change default max_output_tokens to None and add comprehensive model tests (#9366)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-29 21:57:34 -04:00
Boxuan Li
c992b6d2a0 Fix CLI runtime not disabling jupyter plugin by default (#9452) 2025-06-29 17:04:16 -07:00
llamantino
34bf645d64 fix(cli): fix terminal input lag on Windows by start&stopping pause task (#9436) 2025-06-29 10:21:40 -07:00
Graham Neubig
1ae1c16b26 docs: Add repository support and missing options to headless mode documentation (#9311)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-29 01:05:52 +00:00
Boxuan Li
5099413729 Complete browsing unit tests (#9428) 2025-06-28 09:52:52 -07:00
AY
b06a3bdb7c Fixes #9394 - Improve CLI exit messaging to distinguish intentional exits and inter… (#9432) 2025-06-28 18:51:25 +02:00
Xingyao Wang
a7b234d1f6 feat(agent): Add TODO.md workflow for long-horizon tasks into a separate system prompt (#8896)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-28 09:08:13 -04:00
Graham Neubig
2c2a721937 Fix unit tests to be environment-independent for cloud deployment (#9425)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-27 20:43:09 -04:00
AutoLTX
7abad5844a [Feature] Support .cursorrules (#9327)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-28 02:33:19 +02:00
dependabot[bot]
4781e9a424 chore(deps): bump the version-all group across 1 directory with 20 updates (#9421)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-27 20:32:51 -04:00
llamantino
a24d7e636e fix(cli): avoid race condition from multiple process_agent_pause tasks (#9423) 2025-06-27 23:22:43 +00:00
Peter Hamilton
66b95adbc9 Fix: Retry on Bedrock ServiceUnavailableError (#9419)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-27 22:17:50 +02:00
mamoodi
d617d6842a Release 0.47.0 (#9405)
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-27 13:59:36 -04:00
Xingyao Wang
0eb7f956a9 fix(CLI): Reduce severity of pending action timeout messages (#9415)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-06-27 16:28:31 +00:00
Graham Neubig
d3154c4bae Fix CLI import error with broken third-party runtime dependencies (#9413)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-27 12:00:38 -04:00
Calvin Smith
04a15b1467 Condensation request signal in event stream (#9097)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-06-27 09:57:39 -06:00
Xingyao Wang
b74da7d4c3 feat(CLI): Enhance --file option to prompt agent to read and understand file first (#9398)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-27 15:57:29 +00:00
Graham Neubig
70ad469fb2 Fix typing 2025-06-26 23:47:54 -04:00
Graham Neubig
a85f6af9c2 Fix typing in memory module 2025-06-26 23:46:37 -04:00
Graham Neubig
5e213963dc Fix typing 2025-06-26 23:43:13 -04:00
openhands
051c579855 Fix mypy type error in memory.py with reference to GitHub issue #18440 2025-06-27 03:38:50 +00:00
openhands
6d66b8503c Fix mypy type error in memory.py by adding type ignore annotations 2025-06-27 03:20:20 +00:00
Engel Nyst
0fb1a712d5 feat: Add user directory support for microagents (#9333)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-06-26 22:31:59 -04:00
amanape
3b6b6a10d8 fix tests 2025-06-18 20:48:46 +04:00
openhands
5e6553854e Fix trailing whitespace in test file
- Fixed trailing whitespace issues found by pre-commit hooks
- All pre-commit checks now passing
2025-06-18 16:48:16 +00:00
amanape
e8dba65355 merge 2025-06-18 20:23:55 +04:00
amanape
7ebc8be7bb fix lint 2025-06-18 18:18:36 +04:00
sp.wack
c827b0dbb8 Merge branch 'main' into ALL-1986/feat/memory 2025-06-18 17:17:52 +04:00
amanape
a5207bf8c0 remove error callback 2025-06-17 19:41:30 +04:00
amanape
2b05d4c320 refactor 2025-06-17 19:40:04 +04:00
openhands
e5fb016388 feat: Add PR URL detection for microagent finish messages
- Add utility function to parse PR URLs from text with support for GitHub, GitLab, Bitbucket, and Azure DevOps
- Modify microagent status indicator to show 'View your PR' when a PR URL is detected in finish messages
- Update microagent event handler to extract PR URLs from finish actions
- Add comprehensive tests for PR URL parsing and microagent status indicator
- Add internationalization support for 'View your PR' text
- Move tests to frontend/__tests__ directory as per project structure

When a microagent finishes with a PR URL in the final_thought, the status indicator now shows 'View your PR' as a clickable link to the PR instead of the default completion message.
2025-06-17 14:48:09 +00:00
amanape
0519f019c1 fix display in error case 2025-06-17 18:03:26 +04:00
amanape
dc569a629c fixl int 2025-06-17 17:08:37 +04:00
amanape
c7b71cd092 fix microagents issue 2025-06-17 17:05:26 +04:00
amanape
ab15422d77 resolve 2025-06-17 16:34:37 +04:00
amanape
101f40f447 conditional 2025-06-16 19:41:06 +04:00
openhands
0068737636 feat: Add conversation link to microagent status indicator
- Add conversationId prop to MicroagentStatusIndicator component
- Make status text clickable link when status is COMPLETED and conversationId exists
- Link opens microagent conversation in new tab similar to toast behavior
- Pass microagent conversation ID through EventMessage component chain
- Add getMicroagentConversationIdForEvent helper function in Messages component
- Update all MicroagentStatusIndicator usages to pass conversationId

Users can now click on completed microagent status text to view the
microagent conversation directly, providing seamless navigation between
the main conversation and microagent updates.
2025-06-16 14:56:48 +00:00
openhands
2b6e4c4240 feat: Update microagent modal labels and add triggers info icon
- Change textarea label to 'What would you like your microagent to remember?'
- Change triggers label to 'Add triggers for the microagent'
- Add information icon for triggers linking to microagents-keyword docs
- Add new translation keys MICROAGENT$WHAT_TO_REMEMBER and MICROAGENT$ADD_TRIGGERS
- Regenerate i18n declaration file with new keys

The modal now provides clearer guidance on what each field is for and includes
helpful documentation links for users to understand trigger functionality.
2025-06-16 14:43:51 +00:00
openhands
255f910cf6 fix: Remove RUNNING status from microagent status indicator
- Remove MicroagentStatus.RUNNING from component logic
- Remove RUNNING status test case
- Remove MICROAGENT from translation files
- Regenerate i18n declaration file
- Update component to only handle CREATING, COMPLETED, and ERROR states
2025-06-16 14:25:04 +00:00
openhands
b34d555206 feat: Add toast auto-dismiss and microagent dropdown loading state
- Update microagent status toasts to auto-dismiss after 5 seconds
- Add loading state to microagent 'Where should we put it?' dropdown
- Update SettingsDropdownInput to support isLoading prop
- Show loading spinner and placeholder text while fetching microagents
- Disable Launch button when microagents are loading
- Fix MicroagentStatusIndicator to handle RUNNING status
- Update tests to match current component implementation

Users now get better feedback during microagent creation with:
- Auto-dismissing toasts that don't require manual dismissal
- Loading indicators while microagent list is being fetched
- Proper disabled states during loading operations
2025-06-16 14:19:28 +00:00
amanape
a892cb0cf3 status indicators under events 2025-06-16 18:08:37 +04:00
openhands
5808e5587f feat: Add microagent status indicator for event messages
- Add MicroagentStatus enum with 4 states (creating, running, completed, error)
- Create MicroagentStatusIndicator component with visual feedback
- Update Messages component to track microagent status per event
- Modify EventMessage to show status only on messages with actions
- Add socket event handling for status updates
- Include comprehensive test coverage
- Add i18n translations for all status messages

The status indicator appears under the specific event that triggered
the microagent creation, providing real-time feedback to users about
the progress of their microagent requests.
2025-06-16 13:30:50 +00:00
amanape
5069cb82e8 fix values 2025-06-16 17:04:34 +04:00
amanape
70ce1dd400 refactor 2025-06-16 16:49:20 +04:00
openhands
65abbfa39d Fix socket disconnection issues in ConversationSubscriptionsProvider 2025-06-12 16:36:51 +00:00
amanape
4a879f22d7 refactor 2025-06-12 20:32:52 +04:00
amanape
d0db3a8a21 merge 2025-06-12 19:42:39 +04:00
openhands
7afd9ccf93 Add support for multiple conversation subscriptions 2025-06-12 15:28:49 +00:00
amanape
5bea4ab6b7 resolve 2025-06-10 20:24:42 +04:00
amanape
2f819b4f80 remove test 2025-06-09 20:39:07 +04:00
amanape
1284f720ac Better icon 2025-06-09 19:45:08 +04:00
amanape
8374d19b08 Merge branch 'main' into ALL-1986/feat/memory 2025-06-09 19:23:23 +04:00
amanape
5ebae57add Merge branch 'main' into ALL-1986/feat/memory 2025-06-09 17:20:14 +04:00
amanape
d7ac6cbf40 refactor 2025-06-06 18:04:46 +04:00
amanape
8cbbc2331f merge 2025-06-06 17:26:07 +04:00
openhands
0f359373c0 Fix launch-microagent-modal tests by mocking the selector hook 2025-06-05 20:11:09 +00:00
openhands
7eebe16d9e Fix unlocalized strings in microagent components 2025-06-05 18:37:13 +00:00
amanape
1fc4c5d856 loading states 2025-06-05 22:21:44 +04:00
amanape
225966e89e Merge branch 'main' into ALL-1986/feat/memory 2025-06-05 21:43:37 +04:00
amanape
c66d4fdad8 clear socket ref after job finished 2025-06-05 18:07:19 +04:00
amanape
7b71f786bb refactor 2025-06-04 19:20:57 +04:00
amanape
ceac54e767 refactor 2025-06-04 18:32:14 +04:00
amanape
b2a93d9d7f refactor 2025-06-04 18:25:08 +04:00
amanape
00ca066656 comment 2025-06-04 17:45:38 +04:00
amanape
bf560c2b8f refactor 2025-06-04 17:44:52 +04:00
amanape
fd3531223a fix typo 2025-06-04 17:04:34 +04:00
amanape
424c59deb1 fix tessts 2025-06-04 17:01:33 +04:00
amanape
5f036c7011 Merge branch 'main' into ALL-1986/feat/memory 2025-06-04 16:51:49 +04:00
amanape
a044ba85e9 failing checks 2025-06-04 13:10:58 +04:00
amanape
55c7cfd293 wip 2025-06-03 20:29:34 +04:00
amanape
7ba81f952f WIP 2025-06-03 16:56:27 +04:00
amanape
df9f3b2b2b merge 2025-06-02 16:42:36 +04:00
amanape
7f3dd754c3 wip 2025-06-02 16:41:35 +04:00
tofarr
28f4f8f93d Allowing local runtimes to have domains (#8798) 2025-05-30 21:20:23 +04:00
Rohit Malhotra
68eb51eeab [Fix]: inconsistent microagent descriptions (#8800) 2025-05-30 21:20:23 +04:00
Robert Brennan
ca82a3988f add more logging to debug runtime restarts (#8799) 2025-05-30 21:20:23 +04:00
Engel Nyst
4c2039be7e Rename service (#8791) 2025-05-30 21:20:23 +04:00
tofarr
94cbf98771 Fix openapi authorize (#8794) 2025-05-30 21:20:23 +04:00
sp.wack
326651c339 Add git_provider and selected_branch to conversation response (#8795)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-30 21:20:23 +04:00
Engel Nyst
9b2180ec4f Merge branch 'main' of github.com:All-Hands-AI/OpenHands into ALL-1986/feat/memory 2025-05-29 20:41:48 +02:00
amanape
154d18911f small refactor 2025-05-29 17:29:03 +04:00
amanape
05a8c1cf4c merge 2025-05-29 17:05:22 +04:00
amanape
4f567e390a wip 2025-05-22 23:07:15 +04:00
amanape
afc5a41aea refine 2025-05-22 20:44:29 +04:00
amanape
effefa3d56 merge 2025-05-22 19:10:08 +04:00
amanape
cfb4b400a3 Badge input 2025-05-21 23:09:09 +04:00
amanape
fe669bef45 Display toasts of status of new conversation 2025-05-21 19:58:59 +04:00
amanape
429d9100a2 Merge branch 'main' into ALL-1986/feat/memory 2025-05-21 19:08:47 +04:00
openhands
ebc075d5ab Fix socket.io event handling in useSubscribeToConversation and useSocketIO hooks 2025-05-21 15:05:07 +00:00
amanape
3363c6aeb4 refactor 2025-05-21 18:25:56 +04:00
amanape
de99873f66 wip 2025-05-21 17:56:19 +04:00
amanape
d5b3e83d66 Initial commit 2025-05-20 18:02:24 +04:00
125 changed files with 5916 additions and 2153 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -260,6 +260,9 @@ enable_finish = true
# length limit
enable_history_truncation = true
# Whether the condensation request tool is enabled
enable_condensation_request = false
[agent.RepoExplorerAgent]
# Example: use a cheaper model for RepoExplorerAgent to reduce cost, especially
# useful when an agent doesn't demand high quality but uses a lot of tokens

View File

@@ -1,5 +1,5 @@
ARG OPENHANDS_BUILD_VERSION=dev
FROM node:22.16.0-bookworm-slim AS frontend-builder
FROM node:24.3.0-bookworm-slim AS frontend-builder
WORKDIR /app

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.46 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.cli.main --override-cli-mode true
```

View File

@@ -18,42 +18,78 @@ poetry run python -m openhands.core.main -t "write a bash script that prints hi"
You'll need to be sure to set your model, API key, and other settings via environment variables
[or the `config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml).
## With Docker
### Working with Repositories
To run OpenHands in Headless mode with Docker:
You can specify a repository for OpenHands to work with using `--selected-repo` or the `SANDBOX_SELECTED_REPO` environment variable:
1. Set the following environment variables in your terminal:
- `SANDBOX_VOLUMES` to specify the directory you want OpenHands to access ([See using SANDBOX_VOLUMES for more info](../runtimes/docker#using-sandbox_volumes))
- `LLM_MODEL` - the LLM model to use (e.g. `export LLM_MODEL="anthropic/claude-sonnet-4-20250514"`)
- `LLM_API_KEY` - your API key (e.g. `export LLM_API_KEY="sk_test_12345"`)
2. Run the following Docker command:
> **Note**: Currently, authentication tokens (GITHUB_TOKEN, GITLAB_TOKEN, or BITBUCKET_TOKEN) are required for all repository operations, including public repositories. This is a known limitation that may be addressed in future versions to allow tokenless access to public repositories.
```bash
# Using command-line argument
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "analyze the codebase and suggest improvements"
# Using environment variable
export SANDBOX_SELECTED_REPO="owner/repo-name"
poetry run python -m openhands.core.main -t "fix any linting issues"
# Authentication tokens are currently required for ALL repository operations (public and private)
# This includes GitHub, GitLab, and Bitbucket repositories
export GITHUB_TOKEN="your-token" # or GITLAB_TOKEN, BITBUCKET_TOKEN
poetry run python -m openhands.core.main \
--selected-repo "owner/repo-name" \
-t "review the security implementation"
# Using task files instead of inline task
echo "Review the README and suggest improvements" > task.txt
poetry run python -m openhands.core.main -f task.txt --selected-repo "owner/repo"
```
## With Docker
Set environment variables and run the Docker command:
```bash
# Set required environment variables
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
export LLM_API_KEY="your-api-key"
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.46-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
-e LLM_MODEL=$LLM_MODEL \
-e SANDBOX_SELECTED_REPO=$SANDBOX_SELECTED_REPO \
-e GITHUB_TOKEN=$GITHUB_TOKEN \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.46 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Advanced Headless Configurations
## Additional Options
To view all available configuration options for headless mode, run the Python command with the `--help` flag.
Common command-line options:
- `-d "/path/to/workspace"` - Set working directory
- `-f task.txt` - Load task from file
- `-i 50` - Set max iterations
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
### Additional Logs
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
For the headless mode to log all the agent actions, in the terminal run: `export LOG_ALL_EVENTS=true`
Set `export LOG_ALL_EVENTS=true` to log all agent actions.

View File

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

View File

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

View File

@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
});
it.todo("should render an assistant message");
it.skip("should support code syntax highlighting", () => {
it("should support code syntax highlighting", () => {
const code = "```js\nconsole.log('Hello, World!')\n```";
render(<ChatMessage type="user" message={code} />);
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
);
});
it("should display an error toast if copying content to clipboard fails", async () => {});
it("should render a component passed as a prop", () => {
function Component() {
return <div data-testid="custom-component">Custom Component</div>;

View File

@@ -0,0 +1,141 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
import { MemoryService } from "#/api/memory-service/memory-service.api";
import { FileService } from "#/api/file-service/file-service.api";
vi.mock("react-router", async () => ({
useParams: vi.fn().mockReturnValue({
conversationId: "123",
}),
}));
// Mock the useHandleRuntimeActive hook
vi.mock("#/hooks/use-handle-runtime-active", () => ({
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
}));
// Mock the useMicroagentPrompt hook
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
useMicroagentPrompt: vi.fn().mockReturnValue({
data: "Generated prompt",
isLoading: false
}),
}));
// Mock the useGetMicroagents hook
vi.mock("#/hooks/query/use-get-microagents", () => ({
useGetMicroagents: vi.fn().mockReturnValue({
data: ["file1", "file2"]
}),
}));
describe("LaunchMicroagentModal", () => {
const onCloseMock = vi.fn();
const onLaunchMock = vi.fn();
const eventId = 12;
const conversationId = "123";
const renderMicroagentModal = (
{ isLoading }: { isLoading: boolean } = { isLoading: false },
) =>
render(
<LaunchMicroagentModal
onClose={onCloseMock}
onLaunch={onLaunchMock}
eventId={eventId}
selectedRepo="some-repo"
isLoading={isLoading}
/>,
{
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
},
);
afterEach(() => {
vi.clearAllMocks();
});
it("should render the launch microagent modal", () => {
renderMicroagentModal();
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
});
it("should render the form fields", () => {
renderMicroagentModal();
// inputs
screen.getByTestId("query-input");
screen.getByTestId("target-input");
screen.getByTestId("trigger-input");
// action buttons
screen.getByRole("button", { name: "Launch" });
screen.getByRole("button", { name: "Cancel" });
});
it("should call onClose when pressing the cancel button", async () => {
renderMicroagentModal();
const cancelButton = screen.getByRole("button", { name: "Cancel" });
await userEvent.click(cancelButton);
expect(onCloseMock).toHaveBeenCalled();
});
it("should display the prompt from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const descriptionInput = screen.getByTestId("query-input");
expect(descriptionInput).toHaveValue("Generated prompt");
});
it("should display the list of microagent files from the hook", async () => {
renderMicroagentModal();
// Since we're mocking the hook, we just need to verify the UI shows the data
const targetInput = screen.getByTestId("target-input");
expect(targetInput).toHaveValue("");
await userEvent.click(targetInput);
expect(screen.getByText("file1")).toBeInTheDocument();
expect(screen.getByText("file2")).toBeInTheDocument();
await userEvent.click(screen.getByText("file1"));
expect(targetInput).toHaveValue("file1");
});
it("should call onLaunch with the form data", async () => {
renderMicroagentModal();
const triggerInput = screen.getByTestId("trigger-input");
await userEvent.type(triggerInput, "trigger1 ");
await userEvent.type(triggerInput, "trigger2 ");
const targetInput = screen.getByTestId("target-input");
await userEvent.click(targetInput);
await userEvent.click(screen.getByText("file1"));
const launchButton = await screen.findByRole("button", { name: "Launch" });
await userEvent.click(launchButton);
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
"trigger1",
"trigger2",
]);
});
it("should disable the launch button if isLoading is true", async () => {
renderMicroagentModal({ isLoading: true });
const launchButton = screen.getByRole("button", { name: "Launch" });
expect(launchButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,107 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Messages } from "#/components/features/chat/messages";
import {
AssistantMessageAction,
OpenHandsAction,
UserMessageAction,
} from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import OpenHands from "#/api/open-hands";
import { Conversation } from "#/api/open-hands.types";
vi.mock("react-router", () => ({
useParams: () => ({ conversationId: "123" }),
}));
let queryClient: QueryClient;
const renderMessages = ({
messages,
}: {
messages: (OpenHandsAction | OpenHandsObservation)[];
}) => {
const { rerender, ...rest } = render(
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient!}>
{children}
</QueryClientProvider>
),
},
);
const rerenderMessages = (
newMessages: (OpenHandsAction | OpenHandsObservation)[],
) => {
rerender(
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
);
};
return { ...rest, rerender: rerenderMessages };
};
describe("Messages", () => {
beforeEach(() => {
queryClient = new QueryClient();
});
const assistantMessage: AssistantMessageAction = {
id: 0,
action: "message",
source: "agent",
message: "Hello, Assistant!",
timestamp: new Date().toISOString(),
args: {
image_urls: [],
file_urls: [],
thought: "",
wait_for_response: false,
},
};
const userMessage: UserMessageAction = {
id: 1,
action: "message",
source: "user",
message: "Hello, User!",
timestamp: new Date().toISOString(),
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
};
it("should render", () => {
renderMessages({ messages: [userMessage, assistantMessage] });
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
});
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
const mockConversation: Conversation = {
conversation_id: "123",
title: "Test Conversation",
status: "RUNNING",
runtime_status: "STATUS$READY",
created_at: new Date().toISOString(),
last_updated_at: new Date().toISOString(),
selected_branch: null,
selected_repository: null,
git_provider: "github",
session_api_key: null,
url: null,
};
getConversationSpy.mockResolvedValue(mockConversation);
renderMessages({
messages: [userMessage, assistantMessage],
});
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
});
});

View File

@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
"HOME$LETS_START_BUILDING": "Let's start building",
"HOME$LAUNCH_FROM_SCRATCH": "Launch from Scratch",
"HOME$LOADING": "Loading...",
"HOME$OPENHANDS_DESCRIPTION": "OpenHands is an AI software engineer",
"HOME$NOT_SURE_HOW_TO_START": "Not sure how to start?",
"HOME$READ_THIS": "Read this"
HOME$LETS_START_BUILDING: "Let's start building",
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
HOME$LOADING: "Loading...",
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
HOME$READ_THIS: "Read this",
};
return translations[key] || key;
},
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
undefined,
undefined,
undefined,
[],
undefined,
undefined,
undefined,

View File

@@ -176,9 +176,8 @@ describe("RepoConnector", () => {
"rbren/polaris",
"github",
undefined,
[],
undefined,
undefined,
"main",
undefined,
);
});

View File

@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string) => value,
}));
vi.mock("react-router", async (importActual) => ({
...(await importActual()),
useNavigate: vi.fn(),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {

View File

@@ -88,9 +88,14 @@ describe("TaskCard", () => {
MOCK_RESPOSITORIES[0].full_name,
MOCK_RESPOSITORIES[0].git_provider,
undefined,
[],
{
git_provider: "github",
issue_number: 123,
repo: "repo1",
task_type: "MERGE_CONFLICTS",
title: "Task 1",
},
undefined,
MOCK_TASK_1,
undefined,
);
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
@@ -7,6 +7,21 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import userEvent from "@testing-library/user-event";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...(actual as object),
useTranslation: () => ({
t: (key: string) => key,
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([
@@ -93,4 +108,26 @@ describe("TaskSuggestions", () => {
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should render the tooltip button", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toBeInTheDocument();
});
it("should have the correct aria-label", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toHaveAttribute(
"aria-label",
"TASKS$TASK_SUGGESTIONS_INFO",
);
});
it("should render the info icon", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
const icon = tooltipButton.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,62 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
describe("BadgeInput", () => {
it("should render the values", () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
expect(screen.getByText("test")).toBeInTheDocument();
expect(screen.getByText("test2")).toBeInTheDocument();
});
it("should render the input's as a badge on space", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, "test");
await userEvent.type(input, " ");
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
expect(input).toHaveValue("");
});
it("should remove the badge on backspace", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, "{backspace}");
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
expect(input).toHaveValue("");
});
it("should remove the badge on click", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
const removeButton = screen.getByTestId("remove-button");
await userEvent.click(removeButton);
expect(onChangeMock).toHaveBeenCalledWith([]);
});
it("should not create empty badges", async () => {
const onChangeMock = vi.fn();
render(<BadgeInput value={[]} onChange={onChangeMock} />);
const input = screen.getByTestId("badge-input");
expect(input).toHaveValue("");
await userEvent.type(input, " ");
expect(onChangeMock).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
import { MicroagentStatus } from "#/types/microagent-status";
// Mock the translation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("MicroagentStatusIndicator", () => {
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("should show default completed message when status is completed but no PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
/>,
);
const link = screen.getByRole("link", {
name: "MICROAGENT$STATUS_COMPLETED",
});
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
});
it("should show creating status without PR URL", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.CREATING}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
});
it("should show error status", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.ERROR}
conversationId="test-conversation"
/>,
);
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
});
it("should prioritize PR URL over conversation link when both are provided", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
conversationId="test-conversation"
prUrl="https://github.com/owner/repo/pull/123"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://github.com/owner/repo/pull/123",
);
// Should not link to conversation when PR URL is available
expect(link).not.toHaveAttribute(
"href",
"/conversations/test-conversation",
);
});
it("should work with GitLab MR URLs", () => {
render(
<MicroagentStatusIndicator
status={MicroagentStatus.COMPLETED}
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
/>,
);
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
expect(link).toHaveAttribute(
"href",
"https://gitlab.com/owner/repo/-/merge_requests/456",
);
});
});

View File

@@ -0,0 +1,142 @@
import { describe, it, expect } from "vitest";
import {
extractPRUrls,
containsPRUrl,
getFirstPRUrl,
} from "#/utils/parse-pr-url";
describe("parse-pr-url", () => {
describe("extractPRUrls", () => {
it("should extract GitHub PR URLs", () => {
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
const urls = extractPRUrls(text);
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
});
it("should extract GitLab MR URLs", () => {
const text =
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://gitlab.com/owner/repo/-/merge_requests/456",
]);
});
it("should extract Bitbucket PR URLs", () => {
const text =
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://bitbucket.org/owner/repo/pull-requests/789",
]);
});
it("should extract Azure DevOps PR URLs", () => {
const text =
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
]);
});
it("should extract multiple PR URLs", () => {
const text = `
GitHub: https://github.com/owner/repo/pull/123
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
`;
const urls = extractPRUrls(text);
expect(urls).toHaveLength(2);
expect(urls).toContain("https://github.com/owner/repo/pull/123");
expect(urls).toContain(
"https://gitlab.com/owner/repo/-/merge_requests/456",
);
});
it("should handle self-hosted GitLab URLs", () => {
const text =
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
const urls = extractPRUrls(text);
expect(urls).toEqual([
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
]);
});
it("should return empty array when no PR URLs found", () => {
const text = "This is just regular text with no PR URLs";
const urls = extractPRUrls(text);
expect(urls).toEqual([]);
});
it("should handle URLs with HTTP instead of HTTPS", () => {
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
const urls = extractPRUrls(text);
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
});
it("should remove duplicate URLs", () => {
const text = `
Same PR mentioned twice:
https://github.com/owner/repo/pull/123
https://github.com/owner/repo/pull/123
`;
const urls = extractPRUrls(text);
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
});
});
describe("containsPRUrl", () => {
it("should return true when PR URL is present", () => {
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
expect(containsPRUrl(text)).toBe(true);
});
it("should return false when no PR URL is present", () => {
const text = "This is just regular text";
expect(containsPRUrl(text)).toBe(false);
});
});
describe("getFirstPRUrl", () => {
it("should return the first PR URL found", () => {
const text = `
First: https://github.com/owner/repo/pull/123
Second: https://gitlab.com/owner/repo/-/merge_requests/456
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/owner/repo/pull/123");
});
it("should return null when no PR URL is found", () => {
const text = "This is just regular text";
const url = getFirstPRUrl(text);
expect(url).toBeNull();
});
});
describe("real-world scenarios", () => {
it("should handle typical microagent finish messages", () => {
const text = `
I have successfully created a pull request with the requested changes.
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
The changes include:
- Updated the component
- Added tests
- Fixed the issue
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
});
it("should handle messages with PR URLs in the middle", () => {
const text = `
Task completed successfully! I've created a pull request at
https://github.com/owner/repo/pull/567 with all the requested changes.
Please review when you have a chance.
`;
const url = getFirstPRUrl(text);
expect(url).toBe("https://github.com/owner/repo/pull/567");
});
});
});

View File

@@ -90,7 +90,7 @@ describe("HomeScreen", () => {
const mainContainer = screen
.getByTestId("home-screen")
.querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "md:flex-row");
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {

View File

@@ -1,42 +0,0 @@
import { render } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { HomeHeader } from "#/components/features/home/home-header";
// Mock dependencies
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
useCreateConversation: () => ({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
}),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => false,
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("Check for hardcoded English strings in Home components", () => {
test("HomeHeader should not have hardcoded English strings", () => {
const { container } = render(<HomeHeader />);
// Get all text content
const text = container.textContent;
// List of English strings that should be translated
const hardcodedStrings = [
"Launch from Scratch",
"Read this",
];
// Check each string
hardcodedStrings.forEach((str) => {
expect(text).not.toContain(str);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +1,39 @@
{
"name": "openhands-frontend",
"version": "0.46.0",
"version": "0.47.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.0-beta.9",
"@heroui/react": "^2.8.0-beta.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.6.2",
"@react-router/serve": "^7.6.2",
"@react-router/node": "^7.6.3",
"@react-router/serve": "^7.6.3",
"@react-types/shared": "^3.29.1",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.3.1",
"@tailwindcss/postcss": "^4.1.10",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.80.10",
"@vitejs/plugin-react": "^4.5.2",
"@stripe/stripe-js": "^7.4.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.81.4",
"@vitejs/plugin-react": "^4.6.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.18.1",
"framer-motion": "^12.19.2",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.11",
"lucide-react": "^0.519.0",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.255.0",
"posthog-js": "^1.255.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -42,14 +42,14 @@
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.6.2",
"react-router": "^7.6.3",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^6.3.5",
"vite": "^7.0.0",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -80,19 +80,19 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.1",
"@babel/traverse": "^7.27.1",
"@babel/parser": "^7.27.7",
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.1",
"@react-router/dev": "^7.6.2",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.3",
"@types/node": "^24.0.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -117,7 +117,7 @@
"jsdom": "^26.1.0",
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.5.3",
"prettier": "^3.6.2",
"stripe": "^18.2.1",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",

View File

@@ -114,6 +114,7 @@ const EXCLUDED_TECHNICAL_STRINGS = [
"edit-secret-form", // Test ID for secret form
"search-api-key-input", // Input name for search API key
"noopener,noreferrer", // Options for window.open
".openhands/microagents/", // Path to microagents directory
"STATUS$READY",
"STATUS$STOPPED",
"STATUS$ERROR",

View File

@@ -0,0 +1,21 @@
import { openHands } from "../open-hands-axios";
interface GetPromptResponse {
status: string;
prompt: string;
}
export class MemoryService {
static async getPrompt(
conversationId: string,
eventId: number,
): Promise<string> {
const { data } = await openHands.get<GetPromptResponse>(
`/api/conversations/${conversationId}/remember_prompt`,
{
params: { event_id: eventId },
},
);
return data.prompt;
}
}

View File

@@ -258,19 +258,17 @@ class OpenHands {
selectedRepository?: string,
git_provider?: Provider,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
git_provider,
selected_branch,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
replay_json: replayJson,
suggested_task,
conversation_instructions: conversationInstructions,
};
const { data } = await openHands.post<Conversation>(

View File

@@ -1,5 +1,6 @@
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
import { Provider } from "#/types/settings";
export interface ErrorResponse {
error: string;
@@ -77,7 +78,7 @@ export interface Conversation {
title: string;
selected_repository: string | null;
selected_branch: string | null;
git_provider: string | null;
git_provider: Provider | null;
last_updated_at: string;
created_at: string;
status: ConversationStatus;

View File

@@ -12,12 +12,17 @@ import { paragraph } from "../markdown/paragraph";
interface ChatMessageProps {
type: OpenHandsSourceType;
message: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
}>;
}
export function ChatMessage({
type,
message,
children,
actions,
}: React.PropsWithChildren<ChatMessageProps>) {
const [isHovering, setIsHovering] = React.useState(false);
const [isCopy, setIsCopy] = React.useState(false);
@@ -47,31 +52,54 @@ export function ChatMessage({
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className={cn(
"rounded-xl relative",
"rounded-xl relative w-fit",
"flex flex-col gap-2",
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
<div className="text-sm break-words">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
{message}
</Markdown>
<div
className={cn(
"absolute -top-2.5 -right-2.5",
!isHovering ? "hidden" : "flex",
"items-center gap-1",
)}
>
{actions?.map((action, index) => (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
))}
<CopyToClipboardButton
isHidden={!isHovering}
isDisabled={isCopy}
onClick={handleCopyToClipboard}
mode={isCopy ? "copied" : "copy"}
/>
</div>
<div className="text-sm break-words flex">
<div>
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
{message}
</Markdown>
</div>
</div>
{children}
</article>

View File

@@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
import { getObservationResult } from "./event-content-helpers/get-observation-result";
import { getEventContent } from "./event-content-helpers/get-event-content";
import { GenericEventMessage } from "./generic-event-message";
import { MicroagentStatus } from "#/types/microagent-status";
import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator";
import { FileList } from "../files/file-list";
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
import { LikertScale } from "../feedback/likert-scale";
@@ -35,6 +37,13 @@ interface EventMessageProps {
hasObservationPair: boolean;
isAwaitingUserConfirmation: boolean;
isLastMessage: boolean;
microagentStatus?: MicroagentStatus | null;
microagentConversationId?: string;
microagentPRUrl?: string;
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
}>;
isInLast10Actions: boolean;
}
@@ -43,6 +52,10 @@ export function EventMessage({
hasObservationPair,
isAwaitingUserConfirmation,
isLastMessage,
microagentStatus,
microagentConversationId,
microagentPRUrl,
actions,
isInLast10Actions,
}: EventMessageProps) {
const shouldShowConfirmationButtons =
@@ -82,27 +95,66 @@ export function EventMessage({
if (isErrorObservation(event)) {
return (
<>
<div>
<ErrorMessage
errorId={event.extras.error_id}
defaultMessage={event.message}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{renderLikertScale()}
</>
</div>
);
}
if (hasObservationPair && isOpenHandsAction(event)) {
if (hasThoughtProperty(event.args)) {
return <ChatMessage type="agent" message={event.args.thought} />;
return (
<div>
<ChatMessage
type="agent"
message={event.args.thought}
actions={actions}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
</div>
);
}
return null;
return microagentStatus && actions ? (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
) : null;
}
if (isFinishAction(event)) {
return (
<>
<ChatMessage type="agent" message={getEventContent(event).details} />
<ChatMessage
type="agent"
message={getEventContent(event).details}
actions={actions}
/>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{renderLikertScale()}
</>
);
@@ -112,8 +164,8 @@ export function EventMessage({
const message = parseMessageFromEvent(event);
return (
<>
<ChatMessage type={event.source} message={message}>
<div className="flex flex-col self-end">
<ChatMessage type={event.source} message={message} actions={actions}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
)}
@@ -122,15 +174,26 @@ export function EventMessage({
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{isAssistantMessage(event) &&
event.action === "message" &&
renderLikertScale()}
</>
</div>
);
}
if (isRejectObservation(event)) {
return <ChatMessage type="agent" message={event.content} />;
return (
<div>
<ChatMessage type="agent" message={event.content} />
</div>
);
}
if (isMcpObservation(event)) {

View File

@@ -1,10 +1,28 @@
import React from "react";
import { createPortal } from "react-dom";
import { FaBrain } from "react-icons/fa6";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
import {
isOpenHandsAction,
isOpenHandsObservation,
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { EventMessage } from "./event-message";
import { ChatMessage } from "./chat-message";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import {
MicroagentStatus,
EventMicroagentStatus,
} from "#/types/microagent-status";
import { AgentState } from "#/types/agent-state";
import { getFirstPRUrl } from "#/utils/parse-pr-url";
interface MessagesProps {
messages: (OpenHandsAction | OpenHandsObservation)[];
@@ -13,10 +31,23 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) => {
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const { getOptimisticUserMessage } = useOptimisticUserMessage();
const { conversationId } = useConversationId();
const { data: conversation } = useUserConversation(conversationId);
const optimisticUserMessage = getOptimisticUserMessage();
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
null,
);
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
React.useState(false);
const [microagentStatuses, setMicroagentStatuses] = React.useState<
EventMicroagentStatus[]
>([]);
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
@@ -30,6 +61,139 @@ export const Messages: React.FC<MessagesProps> = React.memo(
[messages],
);
const getMicroagentStatusForEvent = React.useCallback(
(eventId: number): MicroagentStatus | null => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.status || null;
},
[microagentStatuses],
);
const getMicroagentConversationIdForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.conversationId || undefined;
},
[microagentStatuses],
);
const getMicroagentPRUrlForEvent = React.useCallback(
(eventId: number): string | undefined => {
const statusEntry = microagentStatuses.find(
(entry) => entry.eventId === eventId,
);
return statusEntry?.prUrl || undefined;
},
[microagentStatuses],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown, microagentConversationId: string) => {
// Handle error events
const isErrorEvent = (
evt: unknown,
): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.ERROR }
: statusEntry,
),
);
} else if (
isOpenHandsEvent(socketEvent) &&
isAgentStateChangeObservation(socketEvent)
) {
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
: statusEntry,
),
);
}
} else if (
isOpenHandsEvent(socketEvent) &&
isFinishAction(socketEvent)
) {
// Check if the finish action contains a PR URL
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
if (prUrl) {
setMicroagentStatuses((prev) =>
prev.map((statusEntry) =>
statusEntry.conversationId === microagentConversationId
? {
...statusEntry,
status: MicroagentStatus.COMPLETED,
prUrl,
}
: statusEntry,
),
);
}
}
},
[setMicroagentStatuses],
);
const handleLaunchMicroagent = (
query: string,
target: string,
triggers: string[],
) => {
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
if (
!conversation ||
!conversation.selected_repository ||
!conversation.selected_branch ||
!conversation.git_provider ||
!selectedEventId
) {
return;
}
createConversationAndSubscribe({
query,
conversationInstructions,
repository: {
name: conversation.selected_repository,
branch: conversation.selected_branch,
gitProvider: conversation.git_provider,
},
onSuccessCallback: (newConversationId: string) => {
setShowLaunchMicroagentModal(false);
// Update status with conversation ID
setMicroagentStatuses((prev) => [
...prev.filter((status) => status.eventId !== selectedEventId),
{
eventId: selectedEventId,
conversationId: newConversationId,
status: MicroagentStatus.CREATING,
},
]);
},
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
handleMicroagentEvent(socketEvent, newConversationId);
},
});
};
return (
<>
{messages.map((message, index) => (
@@ -39,6 +203,20 @@ export const Messages: React.FC<MessagesProps> = React.memo(
hasObservationPair={actionHasObservationPair(message)}
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
isLastMessage={messages.length - 1 === index}
microagentStatus={getMicroagentStatusForEvent(message.id)}
microagentConversationId={getMicroagentConversationIdForEvent(
message.id,
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={[
{
icon: <FaBrain className="w-[14px] h-[14px]" />,
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
},
]}
isInLast10Actions={messages.length - 1 - index < 10}
/>
))}
@@ -46,6 +224,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
{optimisticUserMessage && (
<ChatMessage type="user" message={optimisticUserMessage} />
)}
{conversation?.selected_repository &&
showLaunchMicroagentModal &&
selectedEventId &&
createPortal(
<LaunchMicroagentModal
onClose={() => setShowLaunchMicroagentModal(false)}
onLaunch={handleLaunchMicroagent}
selectedRepo={
conversation.selected_repository.split("/").pop() || ""
}
eventId={selectedEventId}
isLoading={isPending}
/>,
document.getElementById("modal-portal-exit") || document.body,
)}
</>
);
},

View File

@@ -0,0 +1,163 @@
import React from "react";
import { FaCircleInfo } from "react-icons/fa6";
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../../settings/brand-button";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { cn } from "#/utils/utils";
import CloseIcon from "#/icons/close.svg?react";
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
import { LoadingMicroagentBody } from "./loading-microagent-body";
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
interface LaunchMicroagentModalProps {
onClose: () => void;
onLaunch: (query: string, target: string, triggers: string[]) => void;
eventId: number;
isLoading: boolean;
selectedRepo: string;
}
export function LaunchMicroagentModal({
onClose,
onLaunch,
eventId,
isLoading,
selectedRepo,
}: LaunchMicroagentModalProps) {
const { t } = useTranslation();
const { runtimeActive } = useHandleRuntimeActive();
const { data: prompt, isLoading: promptIsLoading } =
useMicroagentPrompt(eventId);
const { data: microagents, isLoading: microagentsIsLoading } =
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
const [triggers, setTriggers] = React.useState<string[]>([]);
const formAction = (formData: FormData) => {
const query = formData.get("query-input")?.toString();
const target = formData.get("target-input")?.toString();
if (query && target) {
onLaunch(query, target, triggers);
}
};
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
formAction(formData);
};
return (
<ModalBackdrop onClose={onClose}>
{!runtimeActive && <LoadingMicroagentBody />}
{runtimeActive && (
<ModalBody className="items-start w-[728px]">
<div className="flex items-center justify-between w-full">
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</h2>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
<form
data-testid="launch-microagent-modal"
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
<label
htmlFor="query-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
{t("MICROAGENT$WHAT_TO_REMEMBER")}
{promptIsLoading && <LoadingMicroagentTextarea />}
{!promptIsLoading && (
<textarea
required
data-testid="query-input"
name="query-input"
defaultValue={prompt}
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
)}
</label>
<SettingsDropdownInput
testId="target-input"
name="target-input"
label={t("MICROAGENT$WHERE_TO_PUT")}
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
required
allowsCustomValue
isLoading={microagentsIsLoading}
items={
microagents?.map((item) => ({
key: item,
label: item,
})) || []
}
/>
<label
htmlFor="trigger-input"
className="flex flex-col gap-2.5 w-full text-sm"
>
<div className="flex items-center gap-2">
{t("MICROAGENT$ADD_TRIGGERS")}
<a
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
target="_blank"
rel="noopener noreferrer"
>
<FaCircleInfo className="text-primary" />
</a>
</div>
<BadgeInput
name="trigger-input"
value={triggers}
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
onChange={setTriggers}
/>
</label>
<div className="flex items-center justify-end gap-2">
<BrandButton type="button" variant="secondary" onClick={onClose}>
{t("MICROAGENT$CANCEL")}
</BrandButton>
<BrandButton
type="submit"
variant="primary"
isDisabled={
isLoading || promptIsLoading || microagentsIsLoading
}
>
{t("MICROAGENT$LAUNCH")}
</BrandButton>
</div>
</form>
</ModalBody>
)}
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,16 @@
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { ModalBody } from "#/components/shared/modals/modal-body";
export function LoadingMicroagentBody() {
const { t } = useTranslation();
return (
<ModalBody>
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
{t("MICROAGENT$ADD_TO_MICROAGENT")}
</h2>
<Spinner size="lg" />
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
</ModalBody>
);
}

View File

@@ -0,0 +1,20 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
export function LoadingMicroagentTextarea() {
const { t } = useTranslation();
return (
<textarea
required
disabled
defaultValue=""
placeholder={t("MICROAGENT$LOADING_PROMPT")}
rows={6}
className={cn(
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
);
}

View File

@@ -0,0 +1,89 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { MicroagentStatus } from "#/types/microagent-status";
import { SuccessIndicator } from "../success-indicator";
interface MicroagentStatusIndicatorProps {
status: MicroagentStatus;
conversationId?: string;
prUrl?: string;
}
export function MicroagentStatusIndicator({
status,
conversationId,
prUrl,
}: MicroagentStatusIndicatorProps) {
const { t } = useTranslation();
const getStatusText = () => {
switch (status) {
case MicroagentStatus.CREATING:
return t("MICROAGENT$STATUS_CREATING");
case MicroagentStatus.COMPLETED:
// If there's a PR URL, show "View your PR" instead of the default completed message
return prUrl
? t("MICROAGENT$VIEW_YOUR_PR")
: t("MICROAGENT$STATUS_COMPLETED");
case MicroagentStatus.ERROR:
return t("MICROAGENT$STATUS_ERROR");
default:
return "";
}
};
const getStatusIcon = () => {
switch (status) {
case MicroagentStatus.CREATING:
return <Spinner size="sm" />;
case MicroagentStatus.COMPLETED:
return <SuccessIndicator status="success" />;
case MicroagentStatus.ERROR:
return <SuccessIndicator status="error" />;
default:
return null;
}
};
const statusText = getStatusText();
const shouldShowAsLink = !!conversationId;
const shouldShowPRLink = !!prUrl;
const renderStatusText = () => {
if (shouldShowPRLink) {
return (
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{statusText}
</a>
);
}
if (shouldShowAsLink) {
return (
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{statusText}
</a>
);
}
return <span className="underline">{statusText}</span>;
};
return (
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
{getStatusIcon()}
{renderStatusText()}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import toast from "react-hot-toast";
import { Spinner } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
import CloseIcon from "#/icons/close.svg?react";
import { SuccessIndicator } from "../success-indicator";
interface ConversationCreatedToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationCreatedToast({
conversationId,
onClose,
}: ConversationCreatedToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<Spinner size="sm" />
<div>
{t("MICROAGENT$ADDING_CONTEXT")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationFinishedToastProps {
conversationId: string;
onClose: () => void;
}
function ConversationFinishedToast({
conversationId,
onClose,
}: ConversationFinishedToastProps) {
const { t } = useTranslation();
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="success" />
<div>
{t("MICROAGENT$SUCCESS_PR_READY")}
<br />
<a
href={`/conversations/${conversationId}`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{t("MICROAGENT$VIEW_CONVERSATION")}
</a>
</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
interface ConversationErroredToastProps {
errorMessage: string;
onClose: () => void;
}
function ConversationErroredToast({
errorMessage,
onClose,
}: ConversationErroredToastProps) {
return (
<div className="flex items-start gap-2">
<SuccessIndicator status="error" />
<div>{errorMessage}</div>
<button type="button" onClick={onClose}>
<CloseIcon />
</button>
</div>
);
}
export const renderConversationCreatedToast = (conversationId: string) =>
toast(
(t) => (
<ConversationCreatedToast
conversationId={conversationId}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);
export const renderConversationFinishedToast = (conversationId: string) =>
toast(
(t) => (
<ConversationFinishedToast
conversationId={conversationId}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);
export const renderConversationErroredToast = (
conversationId: string,
errorMessage: string,
) =>
toast(
(t) => (
<ConversationErroredToast
errorMessage={errorMessage}
onClose={() => toast.dismiss(t.id)}
/>
),
{
...TOAST_OPTIONS,
id: `status-${conversationId}`,
duration: 5000,
},
);

View File

@@ -389,10 +389,7 @@ export function ConversationCard({
/>
{microagentsModalVisible && (
<MicroagentsModal
onClose={() => setMicroagentsModalVisible(false)}
conversationId={conversationId}
/>
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
)}
</>
);

View File

@@ -6,7 +6,12 @@ interface EllipsisButtonProps {
export function EllipsisButton({ onClick }: EllipsisButtonProps) {
return (
<button data-testid="ellipsis-button" type="button" onClick={onClick}>
<button
data-testid="ellipsis-button"
type="button"
onClick={onClick}
className="cursor-pointer"
>
<FaEllipsisV fill="#a3a3a3" />
</button>
);

View File

@@ -9,13 +9,9 @@ import { useConversationMicroagents } from "#/hooks/query/use-conversation-micro
interface MicroagentsModalProps {
onClose: () => void;
conversationId: string | undefined;
}
export function MicroagentsModal({
onClose,
conversationId,
}: MicroagentsModalProps) {
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
const { t } = useTranslation();
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
@@ -25,10 +21,7 @@ export function MicroagentsModal({
data: microagents,
isLoading,
isError,
} = useConversationMicroagents({
conversationId,
enabled: true,
});
} = useConversationMicroagents();
const toggleAgent = (agentName: string) => {
setExpandedAgents((prev) => ({

View File

@@ -118,7 +118,7 @@ export function SystemMessageModal({
)}
</div>
<div className="h-[60vh] overflow-auto rounded-md">
<div className="max-h-[51vh] overflow-auto rounded-md">
{activeTab === "system" && (
<div className="p-4 whitespace-pre-wrap font-mono text-sm leading-relaxed text-gray-300 shadow-inner">
{systemMessage.content}

View File

@@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { BrandButton } from "../settings/brand-button";
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
export function HomeHeader() {
const navigate = useNavigate();
const {
mutate: createConversation,
isPending,
@@ -28,7 +30,15 @@ export function HomeHeader() {
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({})}
onClick={() =>
createConversation(
{},
{
onSuccess: (data) =>
navigate(`/conversations/${data.conversation_id}`),
},
)
}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && t("HOME$LAUNCH_FROM_SCRATCH")}

View File

@@ -1,151 +0,0 @@
import { render, screen } from "@testing-library/react";
import { describe, test, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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();
const mockUseAuth = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseRepositoryBranches.mockReturnValue({
data: [],
isLoading: false,
isError: false,
});
mockUseCreateConversation.mockReturnValue({
mutate: vi.fn(),
isPending: false,
isSuccess: false,
});
mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
// Mock the modules
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(),
}));
vi.mock("#/hooks/use-is-creating-conversation", () => ({
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
const renderRepositorySelectionForm = () =>
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("RepositorySelectionForm", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("shows loading indicator when repositories are being fetched", () => {
// Setup loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderRepositorySelectionForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
test("shows dropdown when repositories are loaded", () => {
// Setup loaded repositories
mockUseUserRepositories.mockReturnValue({
data: [
{
id: 1,
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: 2,
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
],
isLoading: false,
isError: false,
});
renderRepositorySelectionForm();
// Check if dropdown is displayed
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
});
test("shows error message when repository fetch fails", () => {
// Setup error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
error: new Error("Failed to fetch repositories"),
});
renderRepositorySelectionForm();
// Check if error message is displayed
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
});

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
@@ -25,6 +26,7 @@ interface RepositorySelectionFormProps {
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const navigate = useNavigate();
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
@@ -209,10 +211,19 @@ export function RepositorySelectionForm({
isRepositoriesError
}
onClick={() =>
createConversation({
selectedRepository,
selected_branch: selectedBranch?.name,
})
createConversation(
{
repository: {
name: selectedRepository?.full_name || "",
gitProvider: selectedRepository?.git_provider || "github",
branch: selectedBranch?.name || "main",
},
},
{
onSuccess: (data) =>
navigate(`/conversations/${data.conversation_id}`),
},
)
}
>
{!isCreatingConversation && "Launch"}

View File

@@ -3,9 +3,7 @@ import { SuggestedTask } from "./task.types";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { cn } from "#/utils/utils";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { TaskIssueNumber } from "./task-issue-number";
import { Provider } from "#/types/settings";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
const getTaskTypeMap = (
@@ -23,28 +21,19 @@ interface TaskCardProps {
export function TaskCard({ task }: TaskCardProps) {
const { setOptimisticUserMessage } = useOptimisticUserMessage();
const { data: repositories } = useUserRepositories();
const { mutate: createConversation, isPending } = useCreateConversation();
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const getRepo = (repo: string, git_provider: Provider) => {
const selectedRepo = repositories?.find(
(repository) =>
repository.full_name === repo &&
repository.git_provider === git_provider,
);
return selectedRepo;
};
const handleLaunchConversation = () => {
const repo = getRepo(task.repo, task.git_provider);
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
return createConversation({
selectedRepository: repo,
suggested_task: task,
repository: {
name: task.repo,
gitProvider: task.git_provider,
},
suggestedTask: task,
});
};

View File

@@ -1,9 +1,11 @@
import { useTranslation } from "react-i18next";
import { FaInfoCircle } from "react-icons/fa";
import { TaskGroup } from "./task-group";
import { useSuggestedTasks } from "#/hooks/query/use-suggested-tasks";
import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface TaskSuggestionsProps {
filterFor?: string | null;
@@ -23,7 +25,19 @@ export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
data-testid="task-suggestions"
className={cn("flex flex-col w-full", !hasSuggestedTasks && "gap-6")}
>
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<div className="flex items-center gap-2">
<h2 className="heading">{t(I18nKey.TASKS$SUGGESTED_TASKS)}</h2>
<TooltipButton
testId="task-suggestions-info"
tooltip={t(I18nKey.TASKS$TASK_SUGGESTIONS_TOOLTIP)}
ariaLabel={t(I18nKey.TASKS$TASK_SUGGESTIONS_INFO)}
className="text-[#9099AC] hover:text-white"
placement="bottom"
tooltipClassName="max-w-[348px]"
>
<FaInfoCircle size={16} />
</TooltipButton>
</div>
<div className="flex flex-col gap-6">
{isLoading && <TaskSuggestionsSkeleton />}

View File

@@ -32,7 +32,7 @@ export function BrandButton({
type={type}
onClick={onClick}
className={cn(
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80",
"w-fit p-2 text-sm rounded-sm disabled:opacity-30 disabled:cursor-not-allowed hover:opacity-80 cursor-pointer",
variant === "primary" && "bg-primary text-[#0D0F11]",
variant === "secondary" && "border border-primary text-primary",
variant === "danger" && "bg-red-600 text-white hover:bg-red-700",

View File

@@ -1,5 +1,6 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
@@ -12,9 +13,12 @@ interface SettingsDropdownInputProps {
placeholder?: string;
showOptionalTag?: boolean;
isDisabled?: boolean;
isLoading?: boolean;
defaultSelectedKey?: string;
selectedKey?: string;
isClearable?: boolean;
allowsCustomValue?: boolean;
required?: boolean;
onSelectionChange?: (key: React.Key | null) => void;
onInputChange?: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
@@ -29,13 +33,17 @@ export function SettingsDropdownInput({
placeholder,
showOptionalTag,
isDisabled,
isLoading,
defaultSelectedKey,
selectedKey,
isClearable,
allowsCustomValue,
required,
onSelectionChange,
onInputChange,
defaultFilter,
}: SettingsDropdownInputProps) {
const { t } = useTranslation();
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (
@@ -54,8 +62,11 @@ export function SettingsDropdownInput({
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isClearable={isClearable}
isDisabled={isDisabled}
placeholder={placeholder}
isDisabled={isDisabled || isLoading}
isLoading={isLoading}
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
allowsCustomValue={allowsCustomValue}
isRequired={required}
className="w-full"
classNames={{
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",

View File

@@ -26,7 +26,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
};
return (
<div data-testid="user-actions" className="w-8 h-8 relative">
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<UserAvatar
avatarUrl={user?.avatar_url}
onClick={toggleAccountMenu}

View File

@@ -21,7 +21,7 @@ export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
ariaLabel={t(I18nKey.USER$ACCOUNT_SETTINGS)}
onClick={onClick}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center",
"w-8 h-8 rounded-full flex items-center justify-center cursor-pointer",
isLoading && "bg-transparent",
)}
>

View File

@@ -16,7 +16,7 @@ export function SuggestionItem({ suggestion, onClick }: SuggestionItemProps) {
type="button"
data-testid="suggestion"
onClick={() => onClick(suggestion.value)}
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold"
className="text-[16px] leading-6 -tracking-[0.01em] text-center w-full p-3 font-semibold cursor-pointer"
>
{t(suggestion.label)}
</button>

View File

@@ -0,0 +1,21 @@
import { cn } from "#/utils/utils";
interface BrandBadgeProps {
className?: string;
}
export function BrandBadge({
children,
className,
}: React.PropsWithChildren<BrandBadgeProps>) {
return (
<span
className={cn(
"text-sm leading-4 text-[#0D0F11] font-semibold tracking-tighter bg-primary p-1 rounded-full",
className,
)}
>
{children}
</span>
);
}

View File

@@ -20,7 +20,7 @@ export function ActionButton({
<button
onClick={() => handleAction(action)}
disabled={isDisabled}
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out bg-gray-200 p-2 rounded-full"
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
type="button"
>
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">

View File

@@ -29,6 +29,7 @@ export function ConversationPanelButton({
<FaListUl
size={22}
className={cn(
"cursor-pointer",
isOpen ? "text-white" : "text-[#9099AC]",
disabled && "opacity-50",
)}

View File

@@ -27,7 +27,7 @@ export function CopyToClipboardButton({
aria-label={t(
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
)}
className="button-base p-1 absolute top-1 right-1"
className="button-base p-1 cursor-pointer"
>
{mode === "copy" && <CopyIcon width={15} height={15} />}
{mode === "copied" && <CheckmarkIcon width={15} height={15} />}

View File

@@ -15,7 +15,7 @@ export function StopButton({ isDisabled, onClick }: StopButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="button"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
>
<div className="w-[10px] h-[10px] bg-white" />
</button>

View File

@@ -15,7 +15,7 @@ export function SubmitButton({ isDisabled, onClick }: SubmitButtonProps) {
disabled={isDisabled}
onClick={onClick}
type="submit"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center cursor-pointer"
>
<ArrowSendIcon />
</button>

View File

@@ -1,4 +1,4 @@
import { Tooltip } from "@heroui/react";
import { Tooltip, TooltipProps } from "@heroui/react";
import React, { ReactNode } from "react";
import { NavLink } from "react-router";
import { cn } from "#/utils/utils";
@@ -12,7 +12,9 @@ export interface TooltipButtonProps {
ariaLabel: string;
testId?: string;
className?: React.HTMLAttributes<HTMLButtonElement>["className"];
tooltipClassName?: React.HTMLAttributes<HTMLDivElement>["className"];
disabled?: boolean;
placement?: TooltipProps["placement"];
}
export function TooltipButton({
@@ -24,7 +26,9 @@ export function TooltipButton({
ariaLabel,
testId,
className,
tooltipClassName,
disabled = false,
placement = "right",
}: TooltipButtonProps) {
const handleClick = (e: React.MouseEvent) => {
if (onClick && !disabled) {
@@ -118,7 +122,12 @@ export function TooltipButton({
}
return (
<Tooltip content={tooltip} closeDelay={100} placement="right">
<Tooltip
content={tooltip}
closeDelay={100}
placement={placement}
className={tooltipClassName}
>
{content}
</Tooltip>
);

View File

@@ -18,7 +18,7 @@ export function TrajectoryActionButton({
type="button"
data-testid={testId}
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500"
className="button-base p-1 hover:bg-neutral-500 cursor-pointer"
>
{icon}
</button>

View File

@@ -0,0 +1,75 @@
import React from "react";
import { FaX } from "react-icons/fa6";
import { cn } from "#/utils/utils";
import { BrandBadge } from "../badge";
interface BadgeInputProps {
name?: string;
value: string[];
placeholder?: string;
onChange: (value: string[]) => void;
}
export function BadgeInput({
name,
value,
placeholder,
onChange,
}: BadgeInputProps) {
const [inputValue, setInputValue] = React.useState("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// If pressing Backspace with empty input, remove the last badge
if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
const newBadges = [...value];
newBadges.pop();
onChange(newBadges);
return;
}
// If pressing Space or Enter with non-empty input, add a new badge
if (e.key === " " && inputValue.trim() !== "") {
e.preventDefault();
const newBadge = inputValue.trim();
onChange([...value, newBadge]);
setInputValue("");
}
};
const removeBadge = (indexToRemove: number) => {
onChange(value.filter((_, index) => index !== indexToRemove));
};
return (
<div
className={cn(
"bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt",
"flex flex-wrap items-center gap-2",
)}
>
{value.map((badge, index) => (
<div key={index}>
<BrandBadge className="flex items-center gap-0.5">
{badge}
<button
data-testid="remove-button"
type="button"
onClick={() => removeBadge(index)}
>
<FaX className="w-3 h-3 text-black" />
</button>
</BrandBadge>
</div>
))}
<input
data-testid={name || "badge-input"}
name={name}
value={inputValue}
placeholder={value.length === 0 ? placeholder : ""}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-grow outline-none bg-transparent"
/>
</div>
);
}

View File

@@ -68,7 +68,7 @@ export function ModelSelector({
const { t } = useTranslation();
return (
<div className="flex flex-col md:flex-row w-[full] md:w-[680px] justify-between gap-4 md:gap-[46px]">
<div className="flex flex-col md:flex-row w-[full] max-w-[680px] justify-between gap-4 md:gap-[46px]">
<fieldset className="flex flex-col gap-2.5 w-full">
<label className="text-sm">{t(I18nKey.LLM$PROVIDER)}</label>
<Autocomplete

View File

@@ -0,0 +1,345 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useEffect,
useRef,
} from "react";
import { io, Socket } from "socket.io-client";
import { OpenHandsParsedEvent } from "#/types/core";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isStatusUpdate,
} from "#/types/core/guards";
import { AgentState } from "#/types/agent-state";
import {
renderConversationErroredToast,
renderConversationCreatedToast,
renderConversationFinishedToast,
} from "#/components/features/chat/microagent/microagent-status-toast";
interface ConversationSocket {
socket: Socket;
isConnected: boolean;
events: OpenHandsParsedEvent[];
}
interface ConversationSubscriptionsContextType {
activeConversationIds: string[];
subscribeToConversation: (options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => void;
unsubscribeFromConversation: (conversationId: string) => void;
isSubscribedToConversation: (conversationId: string) => boolean;
getEventsForConversation: (conversationId: string) => OpenHandsParsedEvent[];
}
const ConversationSubscriptionsContext =
createContext<ConversationSubscriptionsContextType>({
activeConversationIds: [],
subscribeToConversation: () => {
throw new Error("ConversationSubscriptionsProvider not initialized");
},
unsubscribeFromConversation: () => {
throw new Error("ConversationSubscriptionsProvider not initialized");
},
isSubscribedToConversation: () => false,
getEventsForConversation: () => [],
});
const isErrorEvent = (
event: unknown,
): event is { error: true; message: string } =>
typeof event === "object" &&
event !== null &&
"error" in event &&
event.error === true &&
"message" in event &&
typeof event.message === "string";
const isAgentStatusError = (event: unknown): event is OpenHandsParsedEvent =>
isOpenHandsEvent(event) &&
isAgentStateChangeObservation(event) &&
event.extras.agent_state === AgentState.ERROR;
export function ConversationSubscriptionsProvider({
children,
}: React.PropsWithChildren) {
const [activeConversationIds, setActiveConversationIds] = useState<string[]>(
[],
);
const [conversationSockets, setConversationSockets] = useState<
Record<string, ConversationSocket>
>({});
const eventHandlersRef = useRef<Record<string, (event: unknown) => void>>({});
// Cleanup function to remove all subscriptions when component unmounts
useEffect(
() => () => {
// Store the current sockets in a local variable to avoid closure issues
const socketsToDisconnect = { ...conversationSockets };
if (Object.keys(socketsToDisconnect).length > 0) {
console.warn(
`Cleaning up ${Object.keys(socketsToDisconnect).length} socket connections`,
);
}
Object.values(socketsToDisconnect).forEach((socketData) => {
if (socketData.socket) {
socketData.socket.removeAllListeners();
socketData.socket.disconnect();
}
});
},
[],
);
const unsubscribeFromConversation = useCallback(
(conversationId: string) => {
console.warn(`Unsubscribing from conversation ${conversationId}`);
// Get a local reference to the socket data to avoid race conditions
const socketData = conversationSockets[conversationId];
if (socketData) {
const { socket } = socketData;
const handler = eventHandlersRef.current[conversationId];
if (socket) {
// First remove specific event handlers
if (handler) {
socket.off("oh_event", handler);
}
// Then remove all listeners to be safe
socket.removeAllListeners();
// Finally disconnect the socket
socket.disconnect();
console.warn(
`Socket for conversation ${conversationId} disconnected`,
);
}
// Update state to remove the socket
setConversationSockets((prev) => {
const newSockets = { ...prev };
delete newSockets[conversationId];
return newSockets;
});
// Remove from active IDs
setActiveConversationIds((prev) =>
prev.filter((id) => id !== conversationId),
);
// Clean up event handler reference
delete eventHandlersRef.current[conversationId];
}
},
[conversationSockets],
);
const subscribeToConversation = useCallback(
(options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => {
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
options;
// If already subscribed, don't create a new subscription
if (conversationSockets[conversationId]) {
console.warn(`Already subscribed to conversation ${conversationId}`);
return;
}
console.warn(`Subscribing to conversation ${conversationId}`);
// Create event handler for this subscription
const handleOhEvent = (event: unknown) => {
// Call the custom event handler if provided
if (onEvent) {
onEvent(event, conversationId);
}
// Update the events for this subscription
if (isOpenHandsEvent(event)) {
setConversationSockets((prev) => {
// Make sure the conversation still exists in our state
if (!prev[conversationId]) return prev;
return {
...prev,
[conversationId]: {
...prev[conversationId],
events: [...(prev[conversationId]?.events || []), event],
},
};
});
}
// Handle error events
if (isErrorEvent(event) || isAgentStatusError(event)) {
renderConversationErroredToast(
conversationId,
isErrorEvent(event)
? event.message
: "Unknown error, please try again",
);
} else if (isStatusUpdate(event)) {
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
renderConversationCreatedToast(conversationId);
}
} else if (
isOpenHandsEvent(event) &&
isAgentStateChangeObservation(event)
) {
if (event.extras.agent_state === AgentState.FINISHED) {
renderConversationFinishedToast(conversationId);
unsubscribeFromConversation(conversationId);
}
}
};
// Store the event handler in ref for cleanup
eventHandlersRef.current[conversationId] = handleOhEvent;
try {
// Create socket connection
const socket = io(baseUrl, {
transports: ["websocket"],
query: {
conversation_id: conversationId,
session_api_key: sessionApiKey,
providers_set: providersSet,
},
reconnection: true,
reconnectionAttempts: 5,
reconnectionDelay: 1000,
});
// Set up event listeners
socket.on("connect", () => {
console.warn(`Socket for conversation ${conversationId} CONNECTED!`);
setConversationSockets((prev) => {
// Make sure the conversation still exists in our state
if (!prev[conversationId]) return prev;
return {
...prev,
[conversationId]: {
...prev[conversationId],
isConnected: true,
},
};
});
});
socket.on("connect_error", (error) => {
console.warn(
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
error,
);
});
socket.on("disconnect", (reason) => {
console.warn(
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
reason,
);
setConversationSockets((prev) => {
// Make sure the conversation still exists in our state
if (!prev[conversationId]) return prev;
return {
...prev,
[conversationId]: {
...prev[conversationId],
isConnected: false,
},
};
});
});
socket.on("oh_event", handleOhEvent);
// Add the socket to our state first
setConversationSockets((prev) => ({
...prev,
[conversationId]: {
socket,
isConnected: socket.connected,
events: [],
},
}));
// Then add to active conversation IDs
setActiveConversationIds((prev) =>
prev.includes(conversationId) ? prev : [...prev, conversationId],
);
console.warn(
`Successfully subscribed to conversation ${conversationId}`,
);
} catch (error) {
console.error(
`Error subscribing to conversation ${conversationId}:`,
error,
);
// Clean up the event handler if there was an error
delete eventHandlersRef.current[conversationId];
}
},
[conversationSockets],
);
const isSubscribedToConversation = useCallback(
(conversationId: string) => !!conversationSockets[conversationId],
[conversationSockets],
);
const getEventsForConversation = useCallback(
(conversationId: string) =>
conversationSockets[conversationId]?.events || [],
[conversationSockets],
);
const value = React.useMemo(
() => ({
activeConversationIds,
subscribeToConversation,
unsubscribeFromConversation,
isSubscribedToConversation,
getEventsForConversation,
}),
[
activeConversationIds,
subscribeToConversation,
unsubscribeFromConversation,
isSubscribedToConversation,
getEventsForConversation,
],
);
return (
<ConversationSubscriptionsContext.Provider value={value}>
{children}
</ConversationSubscriptionsContext.Provider>
);
}
export function useConversationSubscriptions() {
return useContext(ConversationSubscriptionsContext);
}

View File

@@ -328,6 +328,7 @@ export function WsClientProvider({
transports: ["websocket"],
query,
});
sio.on("connect", handleConnect);
sio.on("oh_event", handleMessage);
sio.on("connect_error", handleError);

View File

@@ -67,6 +67,7 @@ prepareApp().then(() =>
<QueryClientProvider client={queryClient}>
<HydratedRouter />
<PosthogInit />
<div id="modal-portal-exit" />
</QueryClientProvider>
</Provider>
</StrictMode>,

View File

@@ -1,58 +1,47 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import posthog from "posthog-js";
import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
interface CreateConversationVariables {
query?: string;
repository?: {
name: string;
gitProvider: Provider;
branch?: string;
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
}
export const useCreateConversation = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const { selectedRepository, files, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
);
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
q?: string;
selectedRepository?: GitRepository | null;
selected_branch?: string;
suggested_task?: SuggestedTask;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
mutationFn: async (variables: CreateConversationVariables) => {
const { query, repository, suggestedTask, conversationInstructions } =
variables;
return OpenHands.createConversation(
variables.selectedRepository
? variables.selectedRepository.full_name
: undefined,
variables.selectedRepository
? variables.selectedRepository.git_provider
: undefined,
variables.q,
files,
replayJson || undefined,
variables.suggested_task || undefined,
variables.selected_branch,
repository?.name,
repository?.gitProvider,
query,
suggestedTask,
repository?.branch,
conversationInstructions,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
onSuccess: async (_, { query, repository }) => {
posthog.capture("initial_query_submitted", {
entry_point: "task_form",
query_character_length: q?.length,
has_repository: !!selectedRepository,
has_files: files.length > 0,
has_replay_json: !!replayJson,
query_character_length: query?.length,
has_repository: !!repository,
});
await queryClient.invalidateQueries({
queryKey: ["user", "conversations"],
});
navigate(`/conversations/${conversationId}`);
},
});
};

View File

@@ -1,16 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "../use-conversation-id";
interface UseConversationMicroagentsOptions {
conversationId: string | undefined;
enabled?: boolean;
}
export const useConversationMicroagents = () => {
const { conversationId } = useConversationId();
export const useConversationMicroagents = ({
conversationId,
enabled = true,
}: UseConversationMicroagentsOptions) =>
useQuery({
return useQuery({
queryKey: ["conversation", conversationId, "microagents"],
queryFn: async () => {
if (!conversationId) {
@@ -19,7 +14,8 @@ export const useConversationMicroagents = ({
const data = await OpenHands.getMicroagents(conversationId);
return data.microagents;
},
enabled: !!conversationId && enabled,
enabled: !!conversationId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -1,12 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useConversationId } from "../use-conversation-id";
import OpenHands from "#/api/open-hands";
export const useGetMicroagentPrompt = ({ eventId }: { eventId: number }) => {
const { conversationId } = useConversationId();
return useQuery({
queryKey: ["conversation", "remember_prompt", conversationId, eventId],
queryFn: () => OpenHands.getMicroagentPrompt(conversationId, eventId),
});
};

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { useConversationId } from "../use-conversation-id";
import { FileService } from "#/api/file-service/file-service.api";
export const useGetMicroagents = (microagentDirectory: string) => {
const { conversationId } = useConversationId();
return useQuery({
queryKey: ["files", "microagents", conversationId, microagentDirectory],
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
enabled: !!conversationId,
select: (data) =>
data.map((fileName) => fileName.replace(microagentDirectory, "")),
});
};

View File

@@ -0,0 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { MemoryService } from "#/api/memory-service/memory-service.api";
import { useConversationId } from "../use-conversation-id";
export const useMicroagentPrompt = (eventId: number) => {
const { conversationId } = useConversationId();
return useQuery({
queryKey: ["memory", "prompt", conversationId, eventId],
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
enabled: !!conversationId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -0,0 +1,84 @@
import React from "react";
import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
* multiple conversations simultaneously.
*/
export const useCreateConversationAndSubscribeMultiple = () => {
const { mutate: createConversation, isPending } = useCreateConversation();
const { providers } = useUserProviders();
const {
subscribeToConversation,
unsubscribeFromConversation,
isSubscribedToConversation,
activeConversationIds,
} = useConversationSubscriptions();
const createConversationAndSubscribe = React.useCallback(
({
query,
conversationInstructions,
repository,
onSuccessCallback,
onEventCallback,
}: {
query: string;
conversationInstructions: string;
repository: {
name: string;
branch: string;
gitProvider: Provider;
};
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
createConversation(
{
query,
conversationInstructions,
repository,
},
{
onSuccess: (data) => {
let baseUrl = "";
if (data?.url && !data.url.startsWith("/")) {
baseUrl = new URL(data.url).host;
} else {
baseUrl =
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
window?.location.host;
}
// Subscribe to the conversation
subscribeToConversation({
conversationId: data.conversation_id,
sessionApiKey: data.session_api_key,
providersSet: providers,
baseUrl,
onEvent: onEventCallback,
});
// Call the success callback if provided
if (onSuccessCallback) {
onSuccessCallback(data.conversation_id);
}
},
},
);
},
[createConversation, subscribeToConversation, providers],
);
return {
createConversationAndSubscribe,
unsubscribeFromConversation,
isSubscribedToConversation,
activeConversationIds,
isPending,
};
};

View File

@@ -1,5 +1,26 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT",
MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER",
MICROAGENT$WHAT_TO_REMEMBER = "MICROAGENT$WHAT_TO_REMEMBER",
MICROAGENT$ADD_TRIGGERS = "MICROAGENT$ADD_TRIGGERS",
MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME",
MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT",
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
MICROAGENT$DESCRIBE_WHAT_TO_ADD = "MICROAGENT$DESCRIBE_WHAT_TO_ADD",
MICROAGENT$SELECT_FILE_OR_CUSTOM = "MICROAGENT$SELECT_FILE_OR_CUSTOM",
MICROAGENT$TYPE_TRIGGER_SPACE = "MICROAGENT$TYPE_TRIGGER_SPACE",
MICROAGENT$LOADING_PROMPT = "MICROAGENT$LOADING_PROMPT",
MICROAGENT$CANCEL = "MICROAGENT$CANCEL",
MICROAGENT$LAUNCH = "MICROAGENT$LAUNCH",
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
HOME$READ_THIS = "HOME$READ_THIS",
@@ -611,6 +632,8 @@ export enum I18nKey {
REPOSITORY$SELECT_REPO = "REPOSITORY$SELECT_REPO",
TASKS$SUGGESTED_TASKS = "TASKS$SUGGESTED_TASKS",
TASKS$NO_TASKS_AVAILABLE = "TASKS$NO_TASKS_AVAILABLE",
TASKS$TASK_SUGGESTIONS_INFO = "TASKS$TASK_SUGGESTIONS_INFO",
TASKS$TASK_SUGGESTIONS_TOOLTIP = "TASKS$TASK_SUGGESTIONS_TOOLTIP",
PAYMENT$SPECIFY_AMOUNT_USD = "PAYMENT$SPECIFY_AMOUNT_USD",
GIT$BITBUCKET_TOKEN_HELP_LINK = "GIT$BITBUCKET_TOKEN_HELP_LINK",
GIT$BITBUCKET_TOKEN_SEE_MORE_LINK = "GIT$BITBUCKET_TOKEN_SEE_MORE_LINK",

View File

@@ -1,4 +1,341 @@
{
"MICROAGENT$NO_REPOSITORY_FOUND": {
"en": "No repository found to launch microagent",
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
"zh-CN": "未找到启动微代理的存储库",
"zh-TW": "未找到啟動微代理的存儲庫",
"ko-KR": "마이크로에이전트를 시작할 저장소를 찾을 수 없습니다",
"no": "Ingen repository funnet for å starte mikroagent",
"it": "Nessun repository trovato per avviare il microagente",
"pt": "Nenhum repositório encontrado para iniciar o microagente",
"es": "No se encontró ningún repositorio para iniciar el microagente",
"ar": "لم يتم العثور على مستودع لإطلاق الوكيل المصغر",
"fr": "Aucun dépôt trouvé pour lancer le micro-agent",
"tr": "Mikro ajanı başlatmak için depo bulunamadı",
"de": "Kein Repository gefunden, um Microagent zu starten",
"uk": "Не знайдено репозиторій для запуску мікроагента"
},
"MICROAGENT$ADD_TO_MICROAGENT": {
"en": "Add to Microagent",
"ja": "マイクロエージェントに追加",
"zh-CN": "添加到微代理",
"zh-TW": "添加到微代理",
"ko-KR": "마이크로에이전트에 추가",
"no": "Legg til i mikroagent",
"it": "Aggiungi al microagente",
"pt": "Adicionar ao microagente",
"es": "Añadir al microagente",
"ar": "إضافة إلى الوكيل المصغر",
"fr": "Ajouter au micro-agent",
"tr": "Mikro ajana ekle",
"de": "Zum Microagent hinzufügen",
"uk": "Додати до мікроагента"
},
"MICROAGENT$WHAT_TO_ADD": {
"en": "What would you like to add to the Microagent?",
"ja": "マイクロエージェントに何を追加しますか?",
"zh-CN": "您想添加什么到微代理?",
"zh-TW": "您想添加什麼到微代理?",
"ko-KR": "마이크로에이전트에 무엇을 추가하시겠습니까?",
"no": "Hva vil du legge til i mikroagenten?",
"it": "Cosa vorresti aggiungere al microagente?",
"pt": "O que você gostaria de adicionar ao microagente?",
"es": "¿Qué te gustaría añadir al microagente?",
"ar": "ماذا تريد أن تضيف إلى الوكيل المصغر؟",
"fr": "Que souhaitez-vous ajouter au micro-agent ?",
"tr": "Mikro ajana ne eklemek istersiniz?",
"de": "Was möchten Sie zum Microagent hinzufügen?",
"uk": "Що ви хочете додати до мікроагента?"
},
"MICROAGENT$WHERE_TO_PUT": {
"en": "Where should we put it?",
"ja": "どこに配置しますか?",
"zh-CN": "我们应该把它放在哪里?",
"zh-TW": "我們應該把它放在哪裡?",
"ko-KR": "어디에 넣을까요?",
"no": "Hvor skal vi plassere det?",
"it": "Dove dovremmo metterlo?",
"pt": "Onde devemos colocá-lo?",
"es": "¿Dónde deberíamos ponerlo?",
"ar": "أين يجب أن نضعه؟",
"fr": "Où devons-nous le mettre ?",
"tr": "Nereye koyalım?",
"de": "Wo sollen wir es platzieren?",
"uk": "Куди ми повинні його помістити?"
},
"MICROAGENT$ADD_TRIGGER": {
"en": "Add a trigger for the microagent",
"ja": "マイクロエージェントのトリガーを追加",
"zh-CN": "为微代理添加触发器",
"zh-TW": "為微代理添加觸發器",
"ko-KR": "마이크로에이전트의 트리거 추가",
"no": "Legg til en utløser for mikroagenten",
"it": "Aggiungi un trigger per il microagente",
"pt": "Adicionar um gatilho para o microagente",
"es": "Añadir un disparador para el microagente",
"ar": "إضافة مشغل للوكيل المصغر",
"fr": "Ajouter un déclencheur pour le micro-agent",
"tr": "Mikro ajan için bir tetikleyici ekleyin",
"de": "Fügen Sie einen Auslöser für den Microagent hinzu",
"uk": "Додати тригер для мікроагента"
},
"MICROAGENT$WHAT_TO_REMEMBER": {
"en": "What would you like your microagent to remember?",
"ja": "マイクロエージェントに何を覚えさせたいですか?",
"zh-CN": "您希望您的微代理记住什么?",
"zh-TW": "您希望您的微代理記住什麼?",
"ko-KR": "마이크로에이전트가 무엇을 기억하기를 원하시나요?",
"no": "Hva vil du at mikroagenten din skal huske?",
"it": "Cosa vorresti che il tuo microagente ricordasse?",
"pt": "O que você gostaria que seu microagente lembrasse?",
"es": "¿Qué te gustaría que tu microagente recordara?",
"ar": "ماذا تريد أن يتذكر وكيلك المصغر؟",
"fr": "Que souhaitez-vous que votre micro-agent se souvienne ?",
"tr": "Mikro ajanınızın neyi hatırlamasını istersiniz?",
"de": "Was soll sich Ihr Microagent merken?",
"uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?"
},
"MICROAGENT$ADD_TRIGGERS": {
"en": "Add triggers for the microagent",
"ja": "マイクロエージェントのトリガーを追加",
"zh-CN": "为微代理添加触发器",
"zh-TW": "為微代理添加觸發器",
"ko-KR": "마이크로에이전트의 트리거 추가",
"no": "Legg til utløsere for mikroagenten",
"it": "Aggiungi trigger per il microagente",
"pt": "Adicionar gatilhos para o microagente",
"es": "Añadir disparadores para el microagente",
"ar": "إضافة مشغلات للوكيل المصغر",
"fr": "Ajouter des déclencheurs pour le micro-agent",
"tr": "Mikro ajan için tetikleyiciler ekleyin",
"de": "Auslöser für den Microagent hinzufügen",
"uk": "Додати тригери для мікроагента"
},
"MICROAGENT$WAIT_FOR_RUNTIME": {
"en": "Please wait for the runtime to be active.",
"ja": "ランタイムがアクティブになるまでお待ちください。",
"zh-CN": "请等待运行时激活。",
"zh-TW": "請等待運行時激活。",
"ko-KR": "런타임이 활성화될 때까지 기다려주세요.",
"no": "Vennligst vent til kjøretidsmiljøet er aktivt.",
"it": "Attendere che il runtime sia attivo.",
"pt": "Aguarde até que o tempo de execução esteja ativo.",
"es": "Por favor, espere a que el tiempo de ejecución esté activo.",
"ar": "يرجى الانتظار حتى يصبح وقت التشغيل نشطًا.",
"fr": "Veuillez attendre que le runtime soit actif.",
"tr": "Lütfen çalışma zamanının aktif olmasını bekleyin.",
"de": "Bitte warten Sie, bis die Laufzeitumgebung aktiv ist.",
"uk": "Будь ласка, зачекайте, поки середовище виконання стане активним."
},
"MICROAGENT$ADDING_CONTEXT": {
"en": "OpenHands is adding this new context to your respository. We'll let you know when the pull request is ready.",
"ja": "OpenHandsはこの新しいコンテキストをあなたのリポジトリに追加しています。プルリクエストの準備ができたらお知らせします。",
"zh-CN": "OpenHands正在将此新上下文添加到您的存储库中。拉取请求准备好后我们会通知您。",
"zh-TW": "OpenHands正在將此新上下文添加到您的存儲庫中。拉取請求準備好後我們會通知您。",
"ko-KR": "OpenHands가 이 새로운 컨텍스트를 저장소에 추가하고 있습니다. 풀 리퀘스트가 준비되면 알려드리겠습니다.",
"no": "OpenHands legger til denne nye konteksten i ditt repository. Vi gir deg beskjed når pull-forespørselen er klar.",
"it": "OpenHands sta aggiungendo questo nuovo contesto al tuo repository. Ti faremo sapere quando la pull request sarà pronta.",
"pt": "OpenHands está adicionando este novo contexto ao seu repositório. Avisaremos quando o pull request estiver pronto.",
"es": "OpenHands está añadiendo este nuevo contexto a tu repositorio. Te avisaremos cuando la solicitud de extracción esté lista.",
"ar": "يقوم OpenHands بإضافة هذا السياق الجديد إلى مستودعك. سنعلمك عندما يكون طلب السحب جاهزًا.",
"fr": "OpenHands ajoute ce nouveau contexte à votre dépôt. Nous vous informerons lorsque la pull request sera prête.",
"tr": "OpenHands bu yeni bağlamı deponuza ekliyor. Çekme isteği hazır olduğunda size haber vereceğiz.",
"de": "OpenHands fügt diesen neuen Kontext zu Ihrem Repository hinzu. Wir informieren Sie, wenn der Pull Request bereit ist.",
"uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий."
},
"MICROAGENT$VIEW_CONVERSATION": {
"en": "View Conversation",
"ja": "会話を表示",
"zh-CN": "查看对话",
"zh-TW": "查看對話",
"ko-KR": "대화 보기",
"no": "Vis samtale",
"it": "Visualizza conversazione",
"pt": "Ver conversa",
"es": "Ver conversación",
"ar": "عرض المحادثة",
"fr": "Voir la conversation",
"tr": "Konuşmayı Görüntüle",
"de": "Konversation anzeigen",
"uk": "Переглянути розмову"
},
"MICROAGENT$SUCCESS_PR_READY": {
"en": "Success! Your microagent pull request is ready.",
"ja": "成功!マイクロエージェントのプルリクエストの準備ができました。",
"zh-CN": "成功!您的微代理拉取请求已准备就绪。",
"zh-TW": "成功!您的微代理拉取請求已準備就緒。",
"ko-KR": "성공! 마이크로에이전트 풀 리퀘스트가 준비되었습니다.",
"no": "Suksess! Din mikroagent pull request er klar.",
"it": "Successo! La tua pull request del microagente è pronta.",
"pt": "Sucesso! Seu pull request de microagente está pronto.",
"es": "¡Éxito! Tu solicitud de extracción de microagente está lista.",
"ar": "نجاح! طلب سحب الوكيل المصغر الخاص بك جاهز.",
"fr": "Succès ! Votre pull request de micro-agent est prête.",
"tr": "Başarılı! Mikro ajan çekme isteğiniz hazır.",
"de": "Erfolg! Ihr Microagent Pull Request ist bereit.",
"uk": "Успіх! Ваш запит на витягування мікроагента готовий."
},
"MICROAGENT$STATUS_CREATING": {
"en": "Modifying microagent...",
"ja": "マイクロエージェントを変更中...",
"zh-CN": "正在修改微代理...",
"zh-TW": "正在修改微代理...",
"ko-KR": "마이크로에이전트 수정 중...",
"no": "Endrer mikroagent...",
"it": "Modifica del microagente in corso...",
"pt": "Modificando microagente...",
"es": "Modificando microagente...",
"ar": "تعديل الوكيل المصغر...",
"fr": "Modification du micro-agent en cours...",
"tr": "Mikro ajan değiştiriliyor...",
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
"zh-CN": "查看微代理更新",
"zh-TW": "查看微代理更新",
"ko-KR": "마이크로에이전트 업데이트 보기",
"no": "Vis mikroagent oppdatering",
"it": "Visualizza aggiornamento microagente",
"pt": "Ver atualização do microagente",
"es": "Ver actualización del microagente",
"ar": "عرض تحديث الوكيل المصغر",
"fr": "Voir la mise à jour du micro-agent",
"tr": "Mikro ajan güncellemesini görüntüle",
"de": "Microagent-Update anzeigen",
"uk": "Переглянути оновлення мікроагента"
},
"MICROAGENT$STATUS_ERROR": {
"en": "Microagent encountered an error",
"ja": "マイクロエージェントでエラーが発生しました",
"zh-CN": "微代理遇到错误",
"zh-TW": "微代理遇到錯誤",
"ko-KR": "마이크로에이전트에서 오류가 발생했습니다",
"no": "Mikroagent støtte på en feil",
"it": "Il microagente ha riscontrato un errore",
"pt": "Microagente encontrou um erro",
"es": "El microagente encontró un error",
"ar": "واجه الوكيل المصغر خطأ",
"fr": "Le micro-agent a rencontré une erreur",
"tr": "Mikro ajan bir hatayla karşılaştı",
"de": "Microagent ist auf einen Fehler gestoßen",
"uk": "Мікроагент зіткнувся з помилкою"
},
"MICROAGENT$VIEW_YOUR_PR": {
"en": "View your PR",
"ja": "PRを表示",
"zh-CN": "查看您的PR",
"zh-TW": "查看您的PR",
"ko-KR": "PR 보기",
"no": "Se din PR",
"it": "Visualizza la tua PR",
"pt": "Ver seu PR",
"es": "Ver tu PR",
"ar": "عرض طلب السحب الخاص بك",
"fr": "Voir votre PR",
"tr": "PR'ınızı görüntüleyin",
"de": "Ihre PR anzeigen",
"uk": "Переглянути ваш PR"
},
"MICROAGENT$DESCRIBE_WHAT_TO_ADD": {
"en": "Describe what you want to add to the Microagent...",
"ja": "マイクロエージェントに追加したい内容を説明してください...",
"zh-CN": "描述您想添加到微代理的内容...",
"zh-TW": "描述您想添加到微代理的內容...",
"ko-KR": "마이크로에이전트에 추가하고 싶은 내용을 설명하세요...",
"no": "Beskriv hva du vil legge til i mikroagenten...",
"it": "Descrivi cosa vuoi aggiungere al microagente...",
"pt": "Descreva o que você deseja adicionar ao microagente...",
"es": "Describe lo que quieres añadir al microagente...",
"ar": "صف ما تريد إضافته إلى الوكيل المصغر...",
"fr": "Décrivez ce que vous souhaitez ajouter au micro-agent...",
"tr": "Mikro ajana eklemek istediğinizi açıklayın...",
"de": "Beschreiben Sie, was Sie zum Microagent hinzufügen möchten...",
"uk": "Опишіть, що ви хочете додати до мікроагента..."
},
"MICROAGENT$SELECT_FILE_OR_CUSTOM": {
"en": "Select a microagent file or enter a custom value",
"ja": "マイクロエージェントファイルを選択するか、カスタム値を入力してください",
"zh-CN": "选择微代理文件或输入自定义值",
"zh-TW": "選擇微代理文件或輸入自定義值",
"ko-KR": "마이크로에이전트 파일을 선택하거나 사용자 지정 값을 입력하세요",
"no": "Velg en mikroagent-fil eller skriv inn en egendefinert verdi",
"it": "Seleziona un file microagente o inserisci un valore personalizzato",
"pt": "Selecione um arquivo de microagente ou insira um valor personalizado",
"es": "Selecciona un archivo de microagente o introduce un valor personalizado",
"ar": "حدد ملف وكيل مصغر أو أدخل قيمة مخصصة",
"fr": "Sélectionnez un fichier micro-agent ou entrez une valeur personnalisée",
"tr": "Bir mikro ajan dosyası seçin veya özel bir değer girin",
"de": "Wählen Sie eine Microagent-Datei aus oder geben Sie einen benutzerdefinierten Wert ein",
"uk": "Виберіть файл мікроагента або введіть власне значення"
},
"MICROAGENT$TYPE_TRIGGER_SPACE": {
"en": "Type a trigger and press Space to add it",
"ja": "トリガーを入力し、スペースキーを押して追加してください",
"zh-CN": "输入触发器并按空格键添加",
"zh-TW": "輸入觸發器並按空格鍵添加",
"ko-KR": "트리거를 입력하고 스페이스바를 눌러 추가하세요",
"no": "Skriv inn en utløser og trykk mellomrom for å legge den til",
"it": "Digita un trigger e premi Spazio per aggiungerlo",
"pt": "Digite um gatilho e pressione Espaço para adicioná-lo",
"es": "Escribe un disparador y pulsa Espacio para añadirlo",
"ar": "اكتب مشغلًا واضغط على المسافة لإضافته",
"fr": "Tapez un déclencheur et appuyez sur Espace pour l'ajouter",
"tr": "Bir tetikleyici yazın ve eklemek için Boşluk tuşuna basın",
"de": "Geben Sie einen Auslöser ein und drücken Sie die Leertaste, um ihn hinzuzufügen",
"uk": "Введіть тригер і натисніть пробіл, щоб додати його"
},
"MICROAGENT$LOADING_PROMPT": {
"en": "Loading prompt...",
"ja": "プロンプトを読み込み中...",
"zh-CN": "加载提示中...",
"zh-TW": "加載提示中...",
"ko-KR": "프롬프트 로딩 중...",
"no": "Laster inn prompt...",
"it": "Caricamento prompt...",
"pt": "Carregando prompt...",
"es": "Cargando prompt...",
"ar": "جاري تحميل المطالبة...",
"fr": "Chargement du prompt...",
"tr": "İstem yükleniyor...",
"de": "Prompt wird geladen...",
"uk": "Завантаження підказки..."
},
"MICROAGENT$CANCEL": {
"en": "Cancel",
"ja": "キャンセル",
"zh-CN": "取消",
"zh-TW": "取消",
"ko-KR": "취소",
"no": "Avbryt",
"it": "Annulla",
"pt": "Cancelar",
"es": "Cancelar",
"ar": "إلغاء",
"fr": "Annuler",
"tr": "İptal",
"de": "Abbrechen",
"uk": "Скасувати"
},
"MICROAGENT$LAUNCH": {
"en": "Launch",
"ja": "起動",
"zh-CN": "启动",
"zh-TW": "啟動",
"ko-KR": "시작",
"no": "Start",
"it": "Avvia",
"pt": "Iniciar",
"es": "Iniciar",
"ar": "إطلاق",
"fr": "Lancer",
"tr": "Başlat",
"de": "Starten",
"uk": "Запустити"
},
"STATUS$WEBSOCKET_CLOSED": {
"en": "The WebSocket connection was closed.",
"ja": "WebSocket接続が閉じられました。",
@@ -9775,6 +10112,38 @@
"de": "Keine Aufgaben verfügbar",
"uk": "Немає доступних завдань"
},
"TASKS$TASK_SUGGESTIONS_INFO": {
"en": "Task suggestions information",
"ja": "タスク提案情報",
"zh-CN": "任务建议信息",
"zh-TW": "任務建議資訊",
"ko-KR": "작업 제안 정보",
"no": "Oppgaveforslag informasjon",
"it": "Informazioni sui suggerimenti di attività",
"pt": "Informações de sugestões de tarefas",
"es": "Información de sugerencias de tareas",
"ar": "معلومات اقتراحات المهام",
"fr": "Informations sur les suggestions de tâches",
"tr": "Görev önerisi bilgileri",
"de": "Aufgabenvorschlag-Informationen",
"uk": "Інформація про пропозиції завдань"
},
"TASKS$TASK_SUGGESTIONS_TOOLTIP": {
"en": "These are AI-curated task suggestions to help you get started with common development activities and best practices for your repository.",
"ja": "これらは、リポジトリの一般的な開発活動とベストプラクティスを始めるのに役立つAIによってキュレーションされたタスク提案です。",
"zh-CN": "这些是AI策划的任务建议帮助您开始进行常见的开发活动和存储库的最佳实践。",
"zh-TW": "這些是AI策劃的任務建議幫助您開始進行常見的開發活動和存儲庫的最佳實踐。",
"ko-KR": "이것은 저장소의 일반적인 개발 활동과 모범 사례를 시작할 수 있도록 도와주는 AI가 선별한 작업 제안입니다.",
"no": "Dette er AI-kuraterte oppgaveforslag som hjelper deg å komme i gang med vanlige utviklingsaktiviteter og beste praksis for ditt depot.",
"it": "Questi sono suggerimenti di attività curati dall'IA per aiutarti a iniziare con le attività di sviluppo comuni e le migliori pratiche per il tuo repository.",
"pt": "Estas são sugestões de tarefas curadas por IA para ajudá-lo a começar com atividades de desenvolvimento comuns e melhores práticas para seu repositório.",
"es": "Estas son sugerencias de tareas curadas por IA para ayudarte a comenzar con actividades de desarrollo comunes y mejores prácticas para tu repositorio.",
"ar": "هذه اقتراحات مهام منسقة بواسطة الذكاء الاصطناعي لمساعدتك على البدء بأنشطة التطوير الشائعة وأفضل الممارسات لمستودعك.",
"fr": "Ce sont des suggestions de tâches curées par l'IA pour vous aider à commencer avec les activités de développement courantes et les meilleures pratiques pour votre dépôt.",
"tr": "Bunlar, deponuz için yaygın geliştirme faaliyetleri ve en iyi uygulamalarla başlamanıza yardımcı olmak için AI tarafından düzenlenmiş görev önerileridir.",
"de": "Dies sind KI-kuratierte Aufgabenvorschläge, die Ihnen helfen, mit gängigen Entwicklungsaktivitäten und bewährten Praktiken für Ihr Repository zu beginnen.",
"uk": "Це AI-курировані пропозиції завдань, які допоможуть вам розпочати з поширеними діяльностями розробки та найкращими практиками для вашого репозиторію."
},
"PAYMENT$SPECIFY_AMOUNT_USD": {
"en": "Specify an amount in USD to add - min $10",
"ja": "追加するUSD金額を指定してください - 最小$10",

View File

@@ -192,7 +192,7 @@ function AppSettingsScreen() {
placeholder={t(I18nKey.SETTINGS$MAXIMUM_BUDGET_USD)}
min={1}
step={1}
className="w-[680px]" // Match the width of the language field
className="w-full max-w-[680px]" // Match the width of the language field
/>
</div>
)}

View File

@@ -37,6 +37,7 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import OpenHands from "#/api/open-hands";
import { TabContent } from "#/components/layout/tab-content";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { useUserProviders } from "#/hooks/use-user-providers";
function AppContent() {
@@ -195,23 +196,25 @@ function AppContent() {
return (
<WsClientProvider conversationId={conversationId}>
<EventHandler>
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto">{renderMain()}</div>
<ConversationSubscriptionsProvider>
<EventHandler>
<div data-testid="app-route" className="flex flex-col h-full gap-3">
<div className="flex h-full overflow-auto">{renderMain()}</div>
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings?.SECURITY_ANALYZER}
/>
{settings && (
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
<Controls
setSecurityOpen={onSecurityModalOpen}
showSecurityLock={!!settings?.SECURITY_ANALYZER}
/>
)}
</div>
</EventHandler>
{settings && (
<Security
isOpen={securityModalIsOpen}
onOpenChange={onSecurityModalOpenChange}
securityAnalyzer={settings.SECURITY_ANALYZER}
/>
)}
</div>
</EventHandler>
</ConversationSubscriptionsProvider>
</WsClientProvider>
);
}

View File

@@ -24,7 +24,7 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col md:flex-row justify-between gap-8">
<main className="flex flex-col lg:flex-row justify-between gap-8">
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>

View File

@@ -197,7 +197,7 @@ export default function MainApp() {
return (
<div
data-testid="root-layout"
className="bg-base p-3 h-screen md:min-w-[1024px] flex flex-col md:flex-row gap-3"
className="bg-base p-3 h-screen lg:min-w-[1024px] flex flex-col md:flex-row gap-3"
>
<Sidebar />

View File

@@ -5,6 +5,7 @@ import {
OpenHandsAction,
SystemMessageAction,
CommandAction,
FinishAction,
} from "./actions";
import {
AgentStateChangeObservation,
@@ -15,6 +16,16 @@ import {
} from "./observations";
import { StatusUpdate } from "./variances";
export const isOpenHandsEvent = (
event: unknown,
): event is OpenHandsParsedEvent =>
typeof event === "object" &&
event !== null &&
"id" in event &&
"source" in event &&
"message" in event &&
"timestamp" in event;
export const isOpenHandsAction = (
event: OpenHandsParsedEvent,
): event is OpenHandsAction => "action" in event;
@@ -58,7 +69,7 @@ export const isCommandObservation = (
export const isFinishAction = (
event: OpenHandsParsedEvent,
): event is AssistantMessageAction =>
): event is FinishAction =>
isOpenHandsAction(event) && event.action === "finish";
export const isSystemMessage = (
@@ -76,7 +87,9 @@ export const isMcpObservation = (
): event is MCPObservation =>
isOpenHandsObservation(event) && event.observation === "mcp";
export const isStatusUpdate = (
event: OpenHandsParsedEvent,
): event is StatusUpdate =>
"status_update" in event && "type" in event && "id" in event;
export const isStatusUpdate = (event: unknown): event is StatusUpdate =>
typeof event === "object" &&
event !== null &&
"status_update" in event &&
"type" in event &&
"id" in event;

View File

@@ -35,7 +35,7 @@ interface LocalUserMessageAction {
export interface StatusUpdate {
status_update: true;
type: "error";
type: "error" | "info";
id: string;
message: string;
}

View File

@@ -0,0 +1,12 @@
export enum MicroagentStatus {
CREATING = "creating",
COMPLETED = "completed",
ERROR = "error",
}
export interface EventMicroagentStatus {
eventId: number;
conversationId: string;
status: MicroagentStatus;
prUrl?: string; // Optional PR URL for completed status
}

View File

@@ -8,7 +8,7 @@ const TOAST_STYLE: CSSProperties = {
borderRadius: "4px",
};
const TOAST_OPTIONS: ToastOptions = {
export const TOAST_OPTIONS: ToastOptions = {
position: "top-right",
style: TOAST_STYLE,
};

View File

@@ -0,0 +1,57 @@
/**
* Utility function to parse Pull Request URLs from text
*/
// Common PR URL patterns for different Git providers
const PR_URL_PATTERNS = [
// GitHub: https://github.com/owner/repo/pull/123
/https?:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/\d+/gi,
// GitLab: https://gitlab.com/owner/repo/-/merge_requests/123
/https?:\/\/gitlab\.com\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
// GitLab self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123
/https?:\/\/[^/\s]*gitlab[^/\s]*\/[^/\s]+\/[^/\s]+\/-\/merge_requests\/\d+/gi,
// Bitbucket: https://bitbucket.org/owner/repo/pull-requests/123
/https?:\/\/bitbucket\.org\/[^/\s]+\/[^/\s]+\/pull-requests\/\d+/gi,
// Azure DevOps: https://dev.azure.com/org/project/_git/repo/pullrequest/123
/https?:\/\/dev\.azure\.com\/[^/\s]+\/[^/\s]+\/_git\/[^/\s]+\/pullrequest\/\d+/gi,
// Generic pattern for other providers that might use /pull/ or /pr/
/https?:\/\/[^/\s]+\/[^/\s]+\/[^/\s]+\/(?:pull|pr)\/\d+/gi,
];
/**
* Extracts PR URLs from a given text
* @param text - The text to search for PR URLs
* @returns Array of found PR URLs
*/
export function extractPRUrls(text: string): string[] {
const urls: string[] = [];
for (const pattern of PR_URL_PATTERNS) {
const matches = text.match(pattern);
if (matches) {
urls.push(...matches);
}
}
// Remove duplicates and return
return [...new Set(urls)];
}
/**
* Checks if the text contains any PR URLs
* @param text - The text to check
* @returns True if PR URLs are found, false otherwise
*/
export function containsPRUrl(text: string): boolean {
return extractPRUrls(text).length > 0;
}
/**
* Gets the first PR URL found in the text
* @param text - The text to search
* @returns The first PR URL found, or null if none found
*/
export function getFirstPRUrl(text: string): string | null {
const urls = extractPRUrls(text);
return urls.length > 0 ? urls[0] : null;
}

View File

@@ -12,6 +12,9 @@ if TYPE_CHECKING:
import openhands.agenthub.codeact_agent.function_calling as codeact_function_calling
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
from openhands.agenthub.codeact_agent.tools.browser import BrowserTool
from openhands.agenthub.codeact_agent.tools.condensation_request import (
CondensationRequestTool,
)
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
from openhands.agenthub.codeact_agent.tools.ipython import IPythonTool
from openhands.agenthub.codeact_agent.tools.llm_based_edit import LLMBasedFileEditTool
@@ -119,6 +122,8 @@ class CodeActAgent(Agent):
tools.append(ThinkTool)
if self.config.enable_finish:
tools.append(FinishTool)
if self.config.enable_condensation_request:
tools.append(CondensationRequestTool)
if self.config.enable_browsing:
if sys.platform == 'win32':
logger.warning('Windows runtime does not support browsing yet')

View File

@@ -11,6 +11,7 @@ from litellm import (
from openhands.agenthub.codeact_agent.tools import (
BrowserTool,
CondensationRequestTool,
FinishTool,
IPythonTool,
LLMBasedFileEditTool,
@@ -35,6 +36,7 @@ from openhands.events.action import (
IPythonRunCellAction,
MessageAction,
)
from openhands.events.action.agent import CondensationRequestAction
from openhands.events.action.mcp import MCPAction
from openhands.events.event import FileEditSource, FileReadSource
from openhands.events.tool import ToolCallMetadata
@@ -203,6 +205,12 @@ def response_to_actions(
elif tool_call.function.name == ThinkTool['function']['name']:
action = AgentThinkAction(thought=arguments.get('thought', ''))
# ================================================
# CondensationRequestAction
# ================================================
elif tool_call.function.name == CondensationRequestTool['function']['name']:
action = CondensationRequestAction()
# ================================================
# BrowserTool
# ================================================

View File

@@ -0,0 +1,111 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<ROLE>
Your primary role is to assist users by executing commands, modifying code, and solving technical problems effectively. You should be thorough, methodical, and prioritize quality over speed.
* If the user asks a question, like "why is X happening", don't try to fix the problem. Just give an answer to the question.
</ROLE>
<EFFICIENCY>
* Each action you take is somewhat expensive. Wherever possible, combine multiple actions into a single action, e.g. combine multiple bash commands into one, using sed and grep to edit/view multiple files at once.
* When exploring the codebase, use efficient tools like find, grep, and git commands with appropriate filters to minimize unnecessary operations.
</EFFICIENCY>
<FILE_SYSTEM_GUIDELINES>
* When a user provides a file path, do NOT assume it's relative to the current working directory. First explore the file system to locate the file before working on it.
* If asked to edit a file, edit the file directly, rather than creating a new file with a different filename.
* For global search-and-replace operations, consider using `sed` instead of opening file editors multiple times.
</FILE_SYSTEM_GUIDELINES>
<CODE_QUALITY>
* Write clean, efficient code with minimal comments. Avoid redundancy in comments: Do not repeat information that can be easily inferred from the code itself.
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
* Before implementing any changes, first thoroughly understand the codebase through exploration.
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
</CODE_QUALITY>
<VERSION_CONTROL>
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* Exercise caution with git operations. Do NOT make potentially dangerous changes (e.g., pushing to main, deleting repositories) unless explicitly asked to do so.
* When committing changes, use `git status` to see all modified files, and stage all files necessary for the commit. Use `git commit -a` whenever possible.
* Do NOT commit files that typically shouldn't go into version control (e.g., node_modules/, .env files, build directories, cache files, large binaries) unless explicitly instructed by the user.
* If unsure about committing certain files, check for the presence of .gitignore files or ask the user for clarification.
</VERSION_CONTROL>
<PULL_REQUESTS>
* When creating pull requests, create only ONE per session/issue unless explicitly instructed otherwise.
* When working with an existing PR, update it with new commits rather than creating additional PRs for the same issue.
* When updating a PR, preserve the original PR title and purpose, updating description only when necessary.
</PULL_REQUESTS>
<PROBLEM_SOLVING_WORKFLOW>
1. EXPLORATION: Thoroughly explore relevant files and understand the context before proposing solutions
2. ANALYSIS: Consider multiple approaches and select the most promising one
3. TESTING:
* For bug fixes: Create tests to verify issues before implementing fixes
* For new features: Consider test-driven development when appropriate
* If the repository lacks testing infrastructure and implementing tests would require extensive setup, consult with the user before investing time in building testing infrastructure
* If the environment is not set up to run tests, consult with the user first before investing time to install all dependencies
4. IMPLEMENTATION: Make focused, minimal changes to address the problem
5. VERIFICATION: If the environment is set up to run tests, test your implementation thoroughly, including edge cases. If the environment is not set up to run tests, consult with the user first before investing time to run tests.
</PROBLEM_SOLVING_WORKFLOW>
<TASK_MANAGEMENT>
* For complex, long-horizon tasks, create a TODO.md file to track progress:
1. Start by creating a detailed plan in TODO.md with clear steps
2. Check TODO.md before each new action to maintain context and track progress
3. Update TODO.md as you complete steps or discover new requirements
4. Mark completed items with ✓ or [x] to maintain a clear record of progress
5. For each major step, add sub-tasks as needed to break down complex work
6. If you discover the plan needs significant changes, propose updates and confirm with the user before proceeding and update TODO.md
7. IMPORTANT: Do NOT add TODO.md to git commits or version control systems
* Example TODO.md format:
```markdown
# Task: [Brief description of the overall task]
## Plan
- [ ] Step 1: [Description]
- [ ] Sub-task 1.1
- [ ] Sub-task 1.2
- [ ] Step 2: [Description]
- [x] Step 3: [Description] (Completed)
## Notes
- Important discovery: [Details about something you learned]
- Potential issue: [Description of a potential problem]
```
* When working on a task:
- Read the README to understand how the system works
- Create TODO.md with every major step unchecked
- Add TODO.md to .gitignore if it's not already ignored
- Until every item in TODO.md is checked:
a. Pick the next unchecked item and work on it
b. Run appropriate tests to verify your work
c. If issues arise, fix them until tests pass
d. Once complete, check off the item in TODO.md
e. Proceed to the next unchecked item
</TASK_MANAGEMENT>
<SECURITY>
* Only use GITHUB_TOKEN and other credentials in ways the user has explicitly requested and would expect.
* Use APIs to work with GitHub or other platforms, unless the user asks otherwise or your task requires browsing.
</SECURITY>
<ENVIRONMENT_SETUP>
* When user asks you to run an application, don't stop if the application is not installed. Instead, please install the application and run the command again.
* If you encounter missing dependencies:
1. First, look around in the repository for existing dependency files (requirements.txt, pyproject.toml, package.json, Gemfile, etc.)
2. If dependency files exist, use them to install all dependencies at once (e.g., `pip install -r requirements.txt`, `npm install`, etc.)
3. Only install individual packages directly if no dependency files are found or if only specific packages are needed
* Similarly, if you encounter missing dependencies for essential tools requested by the user, install them when possible.
</ENVIRONMENT_SETUP>
<TROUBLESHOOTING>
* If you've made repeated attempts to solve a problem but tests still fail or the user reports it's still broken:
1. Step back and reflect on 5-7 different possible sources of the problem
2. Assess the likelihood of each possible cause
3. Methodically address the most likely causes, starting with the highest probability
4. Document your reasoning process
* When you run into any major issue while executing a plan from the user, please don't try to directly work around it. Instead, propose a new plan and confirm with the user before proceeding.
</TROUBLESHOOTING>

View File

@@ -1,5 +1,6 @@
from .bash import create_cmd_run_tool
from .browser import BrowserTool
from .condensation_request import CondensationRequestTool
from .finish import FinishTool
from .ipython import IPythonTool
from .llm_based_edit import LLMBasedFileEditTool
@@ -8,6 +9,7 @@ from .think import ThinkTool
__all__ = [
'BrowserTool',
'CondensationRequestTool',
'create_cmd_run_tool',
'FinishTool',
'IPythonTool',

View File

@@ -0,0 +1,16 @@
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
_CONDENSATION_REQUEST_DESCRIPTION = 'Request a condensation of the conversation history when the context becomes too long or when you need to focus on the most relevant information.'
CondensationRequestTool = ChatCompletionToolParam(
type='function',
function=ChatCompletionToolParamFunctionChunk(
name='request_condensation',
description=_CONDENSATION_REQUEST_DESCRIPTION,
parameters={
'type': 'object',
'properties': {},
'required': [],
},
),
)

View File

@@ -28,6 +28,7 @@ from openhands.core.config import (
OpenHandsConfig,
)
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.events import EventSource
from openhands.events.action import (
ChangeAgentStateAction,
@@ -45,10 +46,11 @@ async def handle_commands(
config: OpenHandsConfig,
current_dir: str,
settings_store: FileSettingsStore,
) -> tuple[bool, bool, bool]:
) -> tuple[bool, bool, bool, ExitReason]:
close_repl = False
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.ERROR
if command == '/exit':
close_repl = handle_exit_command(
@@ -57,6 +59,8 @@ async def handle_commands(
usage_metrics,
sid,
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/help':
handle_help_command()
elif command == '/init':
@@ -69,6 +73,8 @@ async def handle_commands(
close_repl, new_session_requested = handle_new_command(
config, event_stream, usage_metrics, sid
)
if close_repl:
exit_reason = ExitReason.INTENTIONAL
elif command == '/settings':
await handle_settings_command(config, settings_store)
elif command == '/resume':
@@ -78,7 +84,7 @@ async def handle_commands(
action = MessageAction(content=command)
event_stream.add_event(action, EventSource.USER)
return close_repl, reload_microagents, new_session_requested
return close_repl, reload_microagents, new_session_requested, exit_reason
def handle_exit_command(

View File

@@ -23,9 +23,10 @@ from openhands.cli.tui import (
display_initialization_animation,
display_runtime_initialization_message,
display_welcome_message,
process_agent_pause,
read_confirmation_input,
read_prompt_input,
start_pause_listener,
stop_pause_listener,
update_streaming_output,
)
from openhands.cli.utils import (
@@ -40,9 +41,11 @@ from openhands.core.config import (
)
from openhands.core.config.condenser_config import NoOpCondenserConfig
from openhands.core.config.mcp_config import OpenHandsMCPConfigImpl
from openhands.core.config.utils import finalize_config
from openhands.core.logger import openhands_logger as logger
from openhands.core.loop import run_agent_until_done
from openhands.core.schema import AgentState
from openhands.core.schema.exit_reason import ExitReason
from openhands.core.setup import (
create_agent,
create_controller,
@@ -77,7 +80,6 @@ async def cleanup_session(
controller: AgentController,
) -> None:
"""Clean up all resources from the current session."""
event_stream = runtime.event_stream
end_state = controller.get_state()
end_state.save_to_session(
@@ -117,6 +119,7 @@ async def run_session(
) -> bool:
reload_microagents = False
new_session_requested = False
exit_reason = ExitReason.INTENTIONAL
sid = generate_sid(config, session_name)
is_loaded = asyncio.Event()
@@ -152,7 +155,7 @@ async def run_session(
usage_metrics = UsageMetrics()
async def prompt_for_next_task(agent_state: str) -> None:
nonlocal reload_microagents, new_session_requested
nonlocal reload_microagents, new_session_requested, exit_reason
while True:
next_message = await read_prompt_input(
config, agent_state, multiline=config.cli_multiline_input
@@ -165,6 +168,7 @@ async def run_session(
close_repl,
reload_microagents,
new_session_requested,
exit_reason,
) = await handle_commands(
next_message,
event_stream,
@@ -183,6 +187,10 @@ async def run_session(
display_event(event, config)
update_usage_metrics(event, usage_metrics)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state not in [AgentState.RUNNING, AgentState.PAUSED]:
await stop_pause_listener()
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
AgentState.AWAITING_USER_INPUT,
@@ -236,9 +244,7 @@ async def run_session(
if event.agent_state == AgentState.RUNNING:
display_agent_running_message()
loop.create_task(
process_agent_pause(is_paused, event_stream)
) # Create a task to track agent pause requests from the user
start_pause_listener(loop, is_paused, event_stream)
def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))
@@ -328,6 +334,11 @@ async def run_session(
await cleanup_session(loop, agent, runtime, controller)
if exit_reason == ExitReason.INTENTIONAL:
print_formatted_text('✅ Session terminated successfully.\n')
else:
print_formatted_text(f'⚠️ Session was interrupted: {exit_reason.value}\n')
return new_session_requested
@@ -423,6 +434,10 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
config.workspace_base = os.getcwd()
config.security.confirmation_mode = True
# Need to finalize config again after setting runtime to 'cli'
# This ensures Jupyter plugin is disabled for CLI runtime
finalize_config(config)
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
@@ -434,7 +449,23 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
return
# Read task from file, CLI args, or stdin
task_str = read_task(args, config.cli_multiline_input)
if args.file:
# For CLI usage, we want to enhance the file content with a prompt
# that instructs the agent to read and understand the file first
with open(args.file, 'r', encoding='utf-8') as file:
file_content = file.read()
# Create a prompt that instructs the agent to read and understand the file first
task_str = f"""The user has tagged a file '{args.file}'.
Please read and understand the following file content first:
```
{file_content}
```
After reviewing the file, please ask the user what they would like to do with it."""
else:
task_str = read_task(args, config.cli_multiline_input)
# Run the first session
new_session_requested = await run_session(
@@ -460,7 +491,7 @@ def main():
try:
loop.run_until_complete(main_with_loop(loop))
except KeyboardInterrupt:
print('Received keyboard interrupt, shutting down...')
print_formatted_text('⚠️ Session was interrupted: interrupted\n')
except ConnectionRefusedError as e:
print(f'Connection refused: {e}')
sys.exit(1)

View File

@@ -3,6 +3,7 @@
# CLI Settings are handled separately in cli_settings.py
import asyncio
import contextlib
import sys
import threading
import time
@@ -75,6 +76,8 @@ COMMANDS = {
print_lock = threading.Lock()
pause_task: asyncio.Task | None = None # No more than one pause task
class UsageMetrics:
def __init__(self) -> None:
@@ -585,6 +588,28 @@ async def read_confirmation_input(config: OpenHandsConfig) -> str:
return 'no'
def start_pause_listener(
loop: asyncio.AbstractEventLoop,
done_event: asyncio.Event,
event_stream,
) -> None:
global pause_task
if pause_task is None or pause_task.done():
pause_task = loop.create_task(
process_agent_pause(done_event, event_stream)
) # Create a task to track agent pause requests from the user
async def stop_pause_listener() -> None:
global pause_task
if pause_task and not pause_task.done():
pause_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pause_task
await asyncio.sleep(0)
pause_task = None
async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) -> None:
input = create_input()
@@ -603,9 +628,12 @@ async def process_agent_pause(done: asyncio.Event, event_stream: EventStream) ->
)
done.set()
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
try:
with input.raw_mode():
with input.attach(keys_ready):
await done.wait()
finally:
input.close()
def cli_confirm(

View File

@@ -59,7 +59,11 @@ from openhands.events.action import (
NullAction,
SystemMessageAction,
)
from openhands.events.action.agent import CondensationAction, RecallAction
from openhands.events.action.agent import (
CondensationAction,
CondensationRequestAction,
RecallAction,
)
from openhands.events.event import Event
from openhands.events.observation import (
AgentDelegateObservation,
@@ -71,7 +75,6 @@ from openhands.events.observation import (
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.memory.view import View
from openhands.storage.files import FileStore
# note: RESUME is only available on web GUI
@@ -336,6 +339,8 @@ class AgentController:
return True
if isinstance(event, CondensationAction):
return True
if isinstance(event, CondensationRequestAction):
return True
return False
if isinstance(event, Observation):
if (
@@ -829,7 +834,9 @@ class AgentController:
or isinstance(e, ContextWindowExceededError)
):
if self.agent.config.enable_history_truncation:
self._handle_long_context_error()
self.event_stream.add_event(
CondensationRequestAction(), EventSource.AGENT
)
return
else:
raise LLMContextWindowExceedError()
@@ -880,7 +887,7 @@ class AgentController:
action_id = getattr(action, 'id', 'unknown')
action_type = type(action).__name__
self.log(
'warning',
'info',
f'Pending action active for {elapsed_time:.2f}s: {action_type} (id={action_id})',
extra={'msg_type': 'PENDING_ACTION_TIMEOUT'},
)
@@ -949,180 +956,6 @@ class AgentController:
assert self._closed
return self.state_tracker.get_trajectory(include_screenshots)
def _handle_long_context_error(self) -> None:
# When context window is exceeded, keep roughly half of agent interactions
current_view = View.from_events(self.state.history)
kept_events = self._apply_conversation_window(current_view.events)
kept_event_ids = {e.id for e in kept_events}
self.log(
'info',
f'Context window exceeded. Keeping events with IDs: {kept_event_ids}',
)
# The events to forget are those that are not in the kept set
forgotten_event_ids = {e.id for e in self.state.history} - kept_event_ids
if len(kept_event_ids) == 0:
self.log(
'warning',
'No events kept after applying conversation window. This should not happen.',
)
# verify that the first event id in kept_event_ids is the same as the start_id
if len(kept_event_ids) > 0 and self.state.history[0].id not in kept_event_ids:
self.log(
'warning',
f'First event after applying conversation window was not kept: {self.state.history[0].id} not in {kept_event_ids}',
)
# Add an error event to trigger another step by the agent
self.event_stream.add_event(
CondensationAction(
forgotten_events_start_id=min(forgotten_event_ids)
if forgotten_event_ids
else 0,
forgotten_events_end_id=max(forgotten_event_ids)
if forgotten_event_ids
else 0,
),
EventSource.AGENT,
)
def _apply_conversation_window(self, history: list[Event]) -> list[Event]:
"""Cuts history roughly in half when context window is exceeded.
It preserves action-observation pairs and ensures that the system message,
the first user message, and its associated recall observation are always included
at the beginning of the context window.
The algorithm:
1. Identify essential initial events: System Message, First User Message, Recall Observation.
2. Determine the slice of recent events to potentially keep.
3. Validate the start of the recent slice for dangling observations.
4. Combine essential events and validated recent events, ensuring essentials come first.
Args:
events: List of events to filter
Returns:
Filtered list of events keeping newest half while preserving pairs and essential initial events.
"""
# Handle empty history
if not history:
return []
# 1. Identify essential initial events
system_message: SystemMessageAction | None = None
first_user_msg: MessageAction | None = None
recall_action: RecallAction | None = None
recall_observation: Observation | None = None
# Find System Message (should be the first event, if it exists)
system_message = next(
(e for e in history if isinstance(e, SystemMessageAction)), None
)
assert (
system_message is None
or isinstance(system_message, SystemMessageAction)
and system_message.id == history[0].id
)
# Find First User Message in the history, which MUST exist
first_user_msg = self._first_user_message(history)
if first_user_msg is None:
# If not found in history, try the event stream
first_user_msg = self._first_user_message()
if first_user_msg is None:
raise RuntimeError('No first user message found in the event stream.')
self.log(
'warning',
'First user message not found in history. Using cached version from event stream.',
)
# Find the first user message index in the history
first_user_msg_index = -1
for i, event in enumerate(history):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
first_user_msg_index = i
break
# Find Recall Action and Observation related to the First User Message
# Look for RecallAction after the first user message
for i in range(first_user_msg_index + 1, len(history)):
event = history[i]
if (
isinstance(event, RecallAction)
and event.query == first_user_msg.content
):
# Found RecallAction, now look for its Observation
recall_action = event
for j in range(i + 1, len(history)):
obs_event = history[j]
# Check for Observation caused by this RecallAction
if (
isinstance(obs_event, Observation)
and obs_event.cause == recall_action.id
):
recall_observation = obs_event
break # Found the observation, stop inner loop
break # Found the recall action (and maybe obs), stop outer loop
essential_events: list[Event] = []
if system_message:
essential_events.append(system_message)
# Only include first user message if history is not empty
if history:
essential_events.append(first_user_msg)
# Include recall action and observation if both exist
if recall_action and recall_observation:
essential_events.append(recall_action)
essential_events.append(recall_observation)
# Include recall action without observation for backward compatibility
elif recall_action:
essential_events.append(recall_action)
# 2. Determine the slice of recent events to potentially keep
num_non_essential_events = len(history) - len(essential_events)
# Keep roughly half of the non-essential events, minimum 1
num_recent_to_keep = max(1, num_non_essential_events // 2)
# Calculate the starting index for the recent slice
slice_start_index = len(history) - num_recent_to_keep
slice_start_index = max(0, slice_start_index) # Ensure index is not negative
recent_events_slice = history[slice_start_index:]
# 3. Validate the start of the recent slice for dangling observations
# IMPORTANT: Most observations in history are tool call results, which cannot be without their action, or we get an LLM API error
first_valid_event_index = 0
for i, event in enumerate(recent_events_slice):
if isinstance(event, Observation):
first_valid_event_index += 1
else:
break
# If all events in the slice are dangling observations, we need to keep at least one
if first_valid_event_index == len(recent_events_slice):
self.log(
'warning',
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.',
)
# Adjust the recent_events_slice if dangling observations were found at the start
if first_valid_event_index < len(recent_events_slice):
validated_recent_events = recent_events_slice[first_valid_event_index:]
if first_valid_event_index > 0:
self.log(
'debug',
f'Removed {first_valid_event_index} dangling observation(s) from the start of recent event slice.',
)
else:
validated_recent_events = []
# 4. Combine essential events and validated recent events
events_to_keep: list[Event] = essential_events + validated_recent_events
self.log('debug', f'History truncated. Kept {len(events_to_keep)} events.')
return events_to_keep
def _is_stuck(self) -> bool:
"""Checks if the agent or its delegate is stuck in a loop.

View File

@@ -31,6 +31,8 @@ class AgentConfig(BaseModel):
"""Whether to enable think tool"""
enable_finish: bool = Field(default=True)
"""Whether to enable finish tool"""
enable_condensation_request: bool = Field(default=False)
"""Whether to enable condensation request tool"""
enable_prompt_extensions: bool = Field(default=True)
"""Whether to enable prompt extensions"""
enable_mcp: bool = Field(default=True)
@@ -51,8 +53,7 @@ class AgentConfig(BaseModel):
@classmethod
def from_toml_section(cls, data: dict) -> dict[str, AgentConfig]:
"""
Create a mapping of AgentConfig instances from a toml dictionary representing the [agent] section.
"""Create a mapping of AgentConfig instances from a toml dictionary representing the [agent] section.
The default configuration is built from all non-dict keys in data.
Then, each key with a dict value is treated as a custom agent configuration, and its values override
@@ -70,7 +71,6 @@ class AgentConfig(BaseModel):
dict[str, AgentConfig]: A mapping where the key "agent" corresponds to the default configuration
and additional keys represent custom configurations.
"""
# Initialize the result mapping
agent_mapping: dict[str, AgentConfig] = {}

View File

@@ -11,7 +11,7 @@ from openhands.core.config.llm_config import LLMConfig
class NoOpCondenserConfig(BaseModel):
"""Configuration for NoOpCondenser."""
type: Literal['noop'] = 'noop'
type: Literal['noop'] = Field(default='noop')
model_config = ConfigDict(extra='forbid')
@@ -19,7 +19,7 @@ class NoOpCondenserConfig(BaseModel):
class ObservationMaskingCondenserConfig(BaseModel):
"""Configuration for ObservationMaskingCondenser."""
type: Literal['observation_masking'] = 'observation_masking'
type: Literal['observation_masking'] = Field(default='observation_masking')
attention_window: int = Field(
default=100,
description='The number of most-recent events where observations will not be masked.',
@@ -32,7 +32,7 @@ class ObservationMaskingCondenserConfig(BaseModel):
class BrowserOutputCondenserConfig(BaseModel):
"""Configuration for the BrowserOutputCondenser."""
type: Literal['browser_output_masking'] = 'browser_output_masking'
type: Literal['browser_output_masking'] = Field(default='browser_output_masking')
attention_window: int = Field(
default=1,
description='The number of most recent browser output observations that will not be masked.',
@@ -43,7 +43,7 @@ class BrowserOutputCondenserConfig(BaseModel):
class RecentEventsCondenserConfig(BaseModel):
"""Configuration for RecentEventsCondenser."""
type: Literal['recent'] = 'recent'
type: Literal['recent'] = Field(default='recent')
# at least one event by default, because the best guess is that it is the user task
keep_first: int = Field(
@@ -61,7 +61,7 @@ class RecentEventsCondenserConfig(BaseModel):
class LLMSummarizingCondenserConfig(BaseModel):
"""Configuration for LLMCondenser."""
type: Literal['llm'] = 'llm'
type: Literal['llm'] = Field(default='llm')
llm_config: LLMConfig = Field(
..., description='Configuration for the LLM to use for condensing.'
)
@@ -88,7 +88,7 @@ class LLMSummarizingCondenserConfig(BaseModel):
class AmortizedForgettingCondenserConfig(BaseModel):
"""Configuration for AmortizedForgettingCondenser."""
type: Literal['amortized'] = 'amortized'
type: Literal['amortized'] = Field(default='amortized')
max_size: int = Field(
default=100,
description='Maximum size of the condensed history before triggering forgetting.',
@@ -108,7 +108,7 @@ class AmortizedForgettingCondenserConfig(BaseModel):
class LLMAttentionCondenserConfig(BaseModel):
"""Configuration for LLMAttentionCondenser."""
type: Literal['llm_attention'] = 'llm_attention'
type: Literal['llm_attention'] = Field(default='llm_attention')
llm_config: LLMConfig = Field(
..., description='Configuration for the LLM to use for attention.'
)
@@ -131,7 +131,7 @@ class LLMAttentionCondenserConfig(BaseModel):
class StructuredSummaryCondenserConfig(BaseModel):
"""Configuration for StructuredSummaryCondenser instances."""
type: Literal['structured'] = 'structured'
type: Literal['structured'] = Field(default='structured')
llm_config: LLMConfig = Field(
..., description='Configuration for the LLM to use for condensing.'
)
@@ -156,12 +156,9 @@ class StructuredSummaryCondenserConfig(BaseModel):
class CondenserPipelineConfig(BaseModel):
"""Configuration for the CondenserPipeline.
"""Configuration for the CondenserPipeline."""
Not currently supported by the TOML or ENV_VAR configuration strategies.
"""
type: Literal['pipeline'] = 'pipeline'
type: Literal['pipeline'] = Field(default='pipeline')
condensers: list[CondenserConfig] = Field(
default_factory=list,
description='List of condenser configurations to be used in the pipeline.',
@@ -170,6 +167,17 @@ class CondenserPipelineConfig(BaseModel):
model_config = ConfigDict(extra='forbid')
class ConversationWindowCondenserConfig(BaseModel):
"""Configuration for ConversationWindowCondenser.
Not currently supported by the TOML or ENV_VAR configuration strategies.
"""
type: Literal['conversation_window'] = Field(default='conversation_window')
model_config = ConfigDict(extra='forbid')
# Type alias for convenience
CondenserConfig = (
NoOpCondenserConfig
@@ -181,14 +189,14 @@ CondenserConfig = (
| LLMAttentionCondenserConfig
| StructuredSummaryCondenserConfig
| CondenserPipelineConfig
| ConversationWindowCondenserConfig
)
def condenser_config_from_toml_section(
data: dict, llm_configs: dict | None = None
) -> dict[str, CondenserConfig]:
"""
Create a CondenserConfig instance from a toml dictionary representing the [condenser] section.
"""Create a CondenserConfig instance from a toml dictionary representing the [condenser] section.
For CondenserConfig, the handling is different since it's a union type. The type of condenser
is determined by the 'type' field in the section.
@@ -210,7 +218,6 @@ def condenser_config_from_toml_section(
Returns:
dict[str, CondenserConfig]: A mapping where the key "condenser" corresponds to the configuration.
"""
# Initialize the result mapping
condenser_mapping: dict[str, CondenserConfig] = {}
@@ -261,8 +268,7 @@ from_toml_section = condenser_config_from_toml_section
def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
"""
Create a CondenserConfig instance based on the specified type.
"""Create a CondenserConfig instance based on the specified type.
Args:
condenser_type: The type of condenser to create.
@@ -284,6 +290,9 @@ def create_condenser_config(condenser_type: str, data: dict) -> CondenserConfig:
'amortized': AmortizedForgettingCondenserConfig,
'llm_attention': LLMAttentionCondenserConfig,
'structured': StructuredSummaryCondenserConfig,
'pipeline': CondenserPipelineConfig,
'conversation_window': ConversationWindowCondenserConfig,
'browser_output_masking': BrowserOutputCondenserConfig,
}
if condenser_type not in condenser_classes:

View File

@@ -91,3 +91,6 @@ class ActionType(str, Enum):
CONDENSATION = 'condensation'
"""Condenses a list of events into a summary."""
CONDENSATION_REQUEST = 'condensation_request'
"""Request for condensation of a list of events."""

View File

@@ -0,0 +1,7 @@
from enum import Enum
class ExitReason(Enum):
INTENTIONAL = 'intentional'
INTERRUPTED = 'interrupted'
ERROR = 'error'

View File

@@ -195,3 +195,18 @@ class CondensationAction(Action):
if self.summary:
return f'Summary: {self.summary}'
return f'Condenser is dropping the events: {self.forgotten}.'
@dataclass
class CondensationRequestAction(Action):
"""This action is used to request a condensation of the conversation history.
Attributes:
action (str): The action type, namely ActionType.CONDENSATION_REQUEST.
"""
action: str = ActionType.CONDENSATION_REQUEST
@property
def message(self) -> str:
return 'Requesting a condensation of the conversation history.'

View File

@@ -9,6 +9,7 @@ from openhands.events.action.agent import (
AgentThinkAction,
ChangeAgentStateAction,
CondensationAction,
CondensationRequestAction,
RecallAction,
)
from openhands.events.action.browse import BrowseInteractiveAction, BrowseURLAction
@@ -43,6 +44,7 @@ actions = (
MessageAction,
SystemMessageAction,
CondensationAction,
CondensationRequestAction,
MCPAction,
)

View File

@@ -19,6 +19,7 @@ from litellm import completion as litellm_completion
from litellm import completion_cost as litellm_completion_cost
from litellm.exceptions import (
RateLimitError,
ServiceUnavailableError,
)
from litellm.types.utils import CostPerToken, ModelResponse, Usage
from litellm.utils import create_pretrained_tokenizer
@@ -40,6 +41,7 @@ __all__ = ['LLM']
# tuple of exceptions to retry on
LLM_RETRY_EXCEPTIONS: tuple[type[Exception], ...] = (
RateLimitError,
ServiceUnavailableError,
litellm.Timeout,
litellm.InternalServerError,
LLMNoResponseError,
@@ -480,24 +482,26 @@ class LLM(RetryMixin, DebugMixin):
)
self.config.top_p = 0.9 if self.config.top_p == 1 else self.config.top_p
# Set the max tokens in an LM-specific way if not set
if self.config.max_input_tokens is None:
if (
self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_input_tokens = self.model_info['max_input_tokens']
else:
# Safe fallback for any potentially viable model
self.config.max_input_tokens = 4096
# Set max_input_tokens from model info if not explicitly set
if (
self.config.max_input_tokens is None
and self.model_info is not None
and 'max_input_tokens' in self.model_info
and isinstance(self.model_info['max_input_tokens'], int)
):
self.config.max_input_tokens = self.model_info['max_input_tokens']
# Set max_output_tokens from model info if not explicitly set
if self.config.max_output_tokens is None:
# Safe default for any potentially viable model
self.config.max_output_tokens = 4096
if self.model_info is not None:
# max_output_tokens has precedence over max_tokens, if either exists.
# litellm has models with both, one or none of these 2 parameters!
# Special case for Claude 3.7 Sonnet models
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Try to get from model info
elif self.model_info is not None:
# max_output_tokens has precedence over max_tokens
if 'max_output_tokens' in self.model_info and isinstance(
self.model_info['max_output_tokens'], int
):
@@ -506,11 +510,6 @@ class LLM(RetryMixin, DebugMixin):
self.model_info['max_tokens'], int
):
self.config.max_output_tokens = self.model_info['max_tokens']
if any(
model in self.config.model
for model in ['claude-3-7-sonnet', 'claude-3.7-sonnet']
):
self.config.max_output_tokens = 64000 # litellm set max to 128k, but that requires a header to be set
# Initialize function calling capability
# Check if model name is in our supported list

View File

@@ -4,6 +4,9 @@ from openhands.memory.condenser.impl.amortized_forgetting_condenser import (
from openhands.memory.condenser.impl.browser_output_condenser import (
BrowserOutputCondenser,
)
from openhands.memory.condenser.impl.conversation_window_condenser import (
ConversationWindowCondenser,
)
from openhands.memory.condenser.impl.llm_attention_condenser import (
ImportantEventSelection,
LLMAttentionCondenser,
@@ -34,4 +37,5 @@ __all__ = [
'RecentEventsCondenser',
'StructuredSummaryCondenser',
'CondenserPipeline',
'ConversationWindowCondenser',
]

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