Compare commits

..

126 Commits

Author SHA1 Message Date
Tim O'Farrell
b0cceae5c2 Fix: Keep the existing behavior in the docker command. (#9724) 2025-07-15 15:34:50 -04:00
Xingyao Wang
3304b944bc chore(cli): make sonnet first in openhands provider model choice (#9719) 2025-07-15 13:39:23 -04:00
mamoodi
56a5f8cc69 Merge branch 'main' into mh/rel049 2025-07-15 10:40:41 -04:00
Xingyao Wang
c419277326 Fix Likert Scale displaying "Star Rating" text instead of star icons (#9708)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 22:38:02 +08:00
Hiep Le
35b945b9d1 refactor(frontend): Display TOS and Privacy policy links on Sign In page (#9697)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-15 14:11:51 +00:00
Boxuan Li
5c3619bc48 Add README for terminal_bench evaluation harness (#9700) 2025-07-15 09:48:34 -04:00
mamoodi
c6500b7a8b Release 0.49.0 2025-07-15 08:45:35 -04:00
Tim O'Farrell
641d0a0bcb Set vscode to use the correct workspace directory (#9698) 2025-07-14 17:40:32 -06:00
dependabot[bot]
fbadea9a6f chore(deps): bump the version-all group in /frontend with 3 updates (#9696)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-14 22:34:26 +04:00
Xingyao Wang
6e25d4bbb6 Add OpenHands provider for LLM through OH Cloud (#9526)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-15 01:44:49 +08:00
sp.wack
127220dc39 chore(ui): npm package config (#9535)
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2025-07-14 20:50:44 +04:00
Tim O'Farrell
9a291e385b Introduced config field to determine whether to init a git repo (#9693) 2025-07-14 10:17:26 -06:00
Tim O'Farrell
95ccec82d9 refactor: make /events endpoint lightweight without requiring active conversation (#9685)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-13 17:14:15 -06:00
Xingyao Wang
4aaa2ccd39 Add CLI alias setup for first-time users (#9542)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-13 15:36:06 +00:00
Tim O'Farrell
bfe0aa08b6 Fix issue where user gets stuck on TOS page (#9676) 2025-07-11 19:28:13 -06:00
Tim O'Farrell
7fb47761c6 Fix: VSCode using Temp Directory in Nested Mode (#9672) 2025-07-11 18:53:05 +00:00
Xuhui Zhou
415931b4dc Update system prompt for interactional system (#9284)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-11 18:07:56 +00:00
Hiep Le
6d57eeb3ed feat: Allow the users to edit the conversation's title. (#9648) 2025-07-11 21:46:51 +04:00
Hiep Le
c03d390772 fix(frontend): The conversation page cannot be used on mobile devices and tablets. (#9558) 2025-07-11 21:43:53 +04:00
dependabot[bot]
a266d4274a chore(deps): bump the version-all group in /frontend with 3 updates (#9669)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-11 21:20:21 +04:00
Engel Nyst
a19cd193d9 Log vscode error in a visible way (#9668)
Co-authored-by: OpenHands Bot <openhands@all-hands.dev>
2025-07-11 15:41:21 +00:00
Ivan Dagelic
4f3e648379 chore: update daytona sdk and proxy endpoint (#9664)
Signed-off-by: Ivan Dagelic <dagelic.ivan@gmail.com>
2025-07-11 17:33:12 +02:00
Tim O'Farrell
b99150c616 Fixes or vscode token / url not being present (#9661) 2025-07-11 09:31:39 -06:00
OpenHands
8937b3fbfc Fix issue #9655: [Bug]: CodeActAgent is incompatible with xAI Grok-4 due to hardcoded stop parameter (#9666)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-11 15:31:11 +00:00
juanmichelini
fb5a39a150 Fix libgl1 package for mswebench base images (#9071) 2025-07-11 10:30:33 -05:00
sp.wack
fc11c15b75 hotfix(ui): Agent message that includes codeblocks overflows (#9667) 2025-07-11 14:35:55 +00:00
Engel Nyst
50a8741d50 Build from vsix first (#9656)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-11 04:53:06 +02:00
xhguo7
9388fef0ef feat(eval): loc acc evaluation (#8515)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-07-11 03:22:35 +08:00
Tim O'Farrell
050e80cc34 Add warm server functionality to local runtime (#9033)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-10 12:12:39 -06:00
Ray Myers
5cc47ee592 Optimize dockerfile by consolidating and reordering steps (#9549) 2025-07-10 12:20:36 -05:00
dependabot[bot]
a09346672f chore(deps): bump the version-all group in /frontend with 3 updates (#9651)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-10 19:09:44 +04:00
ManOwnFire
9e72b69cf8 fix (cli): issue 9386 - show settings.json path in /settings (#9481) 2025-07-10 14:59:06 +00:00
sp.wack
da1f3a5a7b chore(frontend): Ugprade Node requirement to v22 LTS (#9639) 2025-07-10 17:21:03 +04:00
Hiep Le
5c27a452ac refactor(frontend): Make the API keys table styling consistent. (#9630) 2025-07-10 16:07:35 +04:00
Hiep Le
8cb1c738ff refactor(frontend): Make the secrets table styling consistent. (#9628) 2025-07-10 16:05:24 +04:00
Tim O'Farrell
cf276b2e96 All Runtime Status Codes should be in the RuntimeStatus enum (#9601)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 15:34:46 -06:00
sp.wack
1f416f616c chore(ui): Fix late redirects in settings page (#9596)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 17:26:54 +00:00
sp.wack
52775acd4d chore(eslint): Extend eslint rules to error on i18next/on no-literal-string (#9616)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 20:30:24 +04:00
Engel Nyst
be0596abd6 add log-level (#9637) 2025-07-09 11:19:10 -04:00
dependabot[bot]
e77957aa92 chore(deps): bump the version-all group in /frontend with 3 updates (#9635)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-09 18:57:28 +04:00
Eleanor Berger
d04c4c493e Update OpenAI model selection for better agentic coding support (#9597) 2025-07-09 14:44:02 +00:00
Mislav Lukach
5cb534217a feat(ui): spinner component (#9590) 2025-07-09 18:42:29 +04:00
Tim O'Farrell
9331f5e8a7 Fixes for docker nested runtime (#9634) 2025-07-09 08:39:42 -06:00
Hiep Le
8d16567428 refactor(frontend): The Jupyter tab is not showing "Waiting for runtime to start..." when connecting to an agent (#9626) 2025-07-09 18:33:09 +04:00
Xingyao Wang
acc69b74c5 docs: Add CLI installation options with shell aliases and local installation (#9575)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-09 03:42:24 +08:00
mamoodi
28d174a7ce Small documentation updates (#9622)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 15:33:22 -04:00
Xingyao Wang
cff5697456 eval: remove gemini-specific swebench template (#9623) 2025-07-08 18:34:23 +00:00
sp.wack
794eedf503 feat(frontend): Memory UI (#8592)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-08 16:24:07 +00:00
Hiep Le
a6ffb2f799 refactor(frontend): Remove the border bottom of the last element on the suggested tasks. (#9610) 2025-07-08 19:13:51 +04:00
Mislav Lukach
3be3779f68 feat(ui): dialog component (#9591) 2025-07-08 19:06:46 +04:00
sp.wack
222f5fdd51 chore: Update codeowners (#9619) 2025-07-08 15:01:00 +00:00
Mislav Lukach
2066f90654 feat(ui): accordion component (#9537) 2025-07-08 18:57:31 +04:00
dependabot[bot]
9ee2f976a1 chore(deps): bump vite from 7.0.2 to 7.0.3 in /frontend in the version-all group (#9618)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-08 14:54:36 +00:00
Hiep Le
be62df5277 fix(frontend): Jupyter tab requires page refresh to display content (#9614) 2025-07-08 18:30:58 +04:00
Hiep Le
4baf2a64c1 refactor(frontend): Show the git providers on the suggested tasks (#9608) 2025-07-08 18:25:09 +04:00
Hiep Le
2a833325e1 fix(frontend): The suggested tasks section only filters the tasks by the repository’s title. (#9606) 2025-07-08 18:24:30 +04:00
Hiep Le
aa2cacab44 fix(frontend): The terminal is still shown when connecting to an agent. (#9603) 2025-07-08 18:21:06 +04:00
tangwei12
ea07570f62 fix openhands cli loglevel (#9382)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 16:07:13 +02:00
Kenny Dizi
3f5a5005a2 Improve configuration for reasoning_effort (#9572) 2025-07-08 10:05:15 -04:00
mindflow-cn
7acee9e5da Allow workspace_mount_path to use relative paths (#9615)
Co-authored-by: jianchuanli <jianchuanli@langcode.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-08 21:47:28 +08:00
mamoodi
37cbeb735f Some documentation update (#9598)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-08 08:59:08 -04:00
Graham Neubig
c6c6c202f6 Fix CLI thought display order issue (#9417)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 23:33:57 +02:00
Tim O'Farrell
517a72fd0d Use the same event stream instance for conversations as sessions (#9545)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 14:37:17 -06:00
Xingyao Wang
7cfecb6e52 Increase success toast duration to 5 seconds with dynamic calculation (#9574)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-08 02:33:47 +08:00
Tim O'Farrell
8fe2e006ee Added run_in_loop method (#9586)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 11:01:17 -06:00
dependabot[bot]
6d62c341eb chore(deps): bump @heroui/react from 2.8.0-beta.11 to 2.8.0-beta.13 in /frontend in the version-all group (#9587)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 16:48:59 +00:00
Tim O'Farrell
229f35093d perf: make EventStore cur_id a lazy calculated property (#9544)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 09:58:46 -06:00
Rohit Malhotra
21a5e3eed5 Improve error logging in verify_repo_provider before AuthenticationError (#9530)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 10:02:46 -05:00
Hiep Le
97e3310dd5 fix(frontend): the GET microagents API is called multiple times, and the Available Microagents modal loads for an extended period if the conversation is connecting to an agent. (#9517) 2025-07-07 18:08:26 +04:00
Hiep Le
2053e72474 fix(frontend): Not able to scroll the chat input after pasting long content or clicking on a suggested action. (#9550) 2025-07-07 18:06:45 +04:00
dependabot[bot]
300f20368e chore(deps): bump the version-all group in /frontend with 4 updates (#9559)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-07 17:52:34 +04:00
Hiep Le
0bed046fcc fix(frontend): NaN is shown on the conversation card (#9581) 2025-07-07 17:51:56 +04:00
Hiep Le
0bf0dc9316 fix(frontend): [OpenHands Cloud] The Delete API Key modal does not display correctly if the API key name is too long. (#9556) 2025-07-07 17:50:11 +04:00
Hiep Le
0e8d9a8bb4 fix(frontend): [OpenHands Cloud] The API keys table does not display properly if the API key name is too long. (#9554) 2025-07-07 17:49:13 +04:00
Robert Brennan
9280bc34ad Enhance logging for org-level microagent loading to improve debugging (#9471)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 12:31:45 +02:00
Graham Neubig
b132348d22 Fix Jupyter tab not showing input commands (#9533)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-07 09:43:43 +02:00
TakumaNakao
1be77faf94 ADD gemini-2.5 to REASONING_EFFORT_SUPPORTED_MODELS (#9546) 2025-07-06 06:31:41 +00:00
Boxuan Li
a6301075ec Add core config to disable browser environment (#9570) 2025-07-06 08:20:58 +02:00
Boxuan Li
b98615bc1c Mark memory-profiler & jupyter_kernel_gateway dependency as non-optional (#9562) 2025-07-06 04:13:21 +02:00
Boxuan Li
29fdc701a3 Jupyter: remove poetry dependency (#9561) 2025-07-05 15:03:26 -07:00
Ryan H. Tran
8bc9207c24 Add instruction to use Arctic Inference (#9547) 2025-07-04 20:34:05 +07:00
Hiep Le
96008736a4 fix(frontend): [OpenHands Cloud] The input outline on the user setting page is not consistent. (#9552) 2025-07-04 13:23:03 +04:00
Robert Brennan
38d5db0547 Fix capitalization in 'Initializing agent...' status message (#9406)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-04 09:56:40 +02:00
Robert Brennan
8af1f1cac9 Add labels support to PR and MR creation tools (#9402)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-04 09:55:54 +02:00
Engel Nyst
ef502ccba8 Add CLI/vscode integration (#9085)
Co-authored-by: OpenHands-Gemini <openhands@all-hands.dev>
Co-authored-by: Claude 3.5 Sonnet <claude-3-5-sonnet@anthropic.com>
2025-07-03 22:42:06 +02:00
Tim O'Farrell
ece556c047 Fix issue where coro was not awaited (#9536) 2025-07-03 13:32:51 -06:00
Tim O'Farrell
55a09785ce Fix for issue where wrong method was called (#9532) 2025-07-03 11:46:48 -06:00
Mislav Lukach
2990c21d97 fix(ui): fix base components styling (#9528) 2025-07-03 21:21:18 +04:00
dependabot[bot]
14c8ea93c9 chore(deps): bump vite from 7.0.0 to 7.0.1 in /frontend in the version-all group (#9529)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-03 21:20:22 +04:00
Mislav Lukach
764077ef3d Feat/create UI dir (#9462)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-03 13:26:19 +00:00
Hiep Le
63ead2a638 fix(frontend): The "available microagents" modal does not show the latest agents after adding a new agent or updating the current agents (#9502) 2025-07-03 13:11:06 +00:00
Hiep Le
be0049c76e fix(frontend): Some strings are not included in the translation file. (#9524) 2025-07-03 12:55:13 +00:00
Hiep Le
bafd1596dd fix(frontend): The secret settings layout will be broken if the secret name is too long. (#9522) 2025-07-03 12:54:47 +00:00
Hiep Le
ce58ccab8a fix(frontend): Changing languages on the settings page does not work for some languages. (#9515) 2025-07-03 16:35:52 +04:00
sp.wack
b3c8b7c089 Fix WebSocket disconnection when uploading large files (#9504)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-03 16:28:30 +04:00
Engel Nyst
ac2947b7ff Fix /init on CLI Runtime (#9474)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-07-03 08:28:18 -04:00
mamoodi
91cd647f20 Add item to troubleshooting guide (#9490) 2025-07-02 16:31:26 -04:00
mamoodi
c521fb7a8f Release 0.48.0 (#9491) 2025-07-02 16:21:45 -04:00
Rohit Malhotra
f049411631 (Hotfix): Microagent won't load depending on version number format (#9508)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 18:06:36 +00:00
Tim O'Farrell
606ec59b33 Fix CLI confirmation input to handle invalid input properly (#9503)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 10:48:43 -06:00
Graham Neubig
d2fc5679ad Improve rate limit message to indicate automatic retry (#9281)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-02 12:27:35 -04:00
Hiep Le
7bfa05d38a refactor(frontend): Show branch name and git provider on the conversation cards (#9480)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-07-02 16:04:55 +00:00
dependabot[bot]
12a95fb548 chore(deps): bump the version-all group in /frontend with 7 updates (#9506)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-02 15:08:05 +00:00
llamantino
ae03c4eb80 chore: bump openhands-aci to 0.3.1 to fix ffmpeg warning (#9500) 2025-07-02 13:49:51 +00:00
mindflow-cn
8e486dfd6b Replace libtmux's deprecated methods in bash.py (#9463)
Co-authored-by: jianchuanli <jianchuanli@langcode.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-07-01 21:07:48 -04:00
Rohit Malhotra
48ee5659c9 Conditionally render 'Add GitHub repos' link based on provider (#9499)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 20:56:00 -04:00
Graham Neubig
b7613d7529 Fix feedback endpoint calls in OSS mode (#9476)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 16:31:05 -04:00
Graham Neubig
e05e627957 Add ArcticInference doc (#9492) 2025-07-01 14:15:13 -04:00
mamoodi
6da7e051be Make roadmap labels exempt from going stale (#9484) 2025-07-01 12:56:36 -04:00
dependabot[bot]
002e12a049 chore(deps): bump the version-all group in /frontend with 5 updates (#9486)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 20:09:02 +04:00
Graham Neubig
ed58858e03 Add setup.sh script execution to event stream (#9427)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-01 10:37:21 -04:00
Hiep Le
11ae4f96c2 fix(frontend): The "logout" action is still shown even if there is no associated account. (#9478) 2025-07-01 16:51:59 +04:00
Hiep Le
c2acf4e07e fix(frontend): Updated LLM settings are not applied to existing conversations. (#9460) 2025-06-30 16:52:59 +00:00
sp.wack
e9bdf761b7 hotfix(frontend): Fix action button cutoff (#9465) 2025-06-30 20:32:52 +04:00
Hiep Le
04b93069b4 feat(frontend): Stop conversation (#9458) 2025-06-30 20:31:37 +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
334 changed files with 29951 additions and 3842 deletions

1
.github/CODEOWNERS vendored
View File

@@ -3,6 +3,7 @@
# Frontend code owners
/frontend/ @rbren @amanape
/openhands-ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig

View File

@@ -9,8 +9,8 @@ on:
- main
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/fe-unit-tests.yml'
- "frontend/**"
- ".github/workflows/fe-unit-tests.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
@@ -24,7 +24,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
strategy:
matrix:
node-version: [20, 22]
node-version: 22
fail-fast: true
steps:
- name: Checkout
@@ -38,7 +38,7 @@ jobs:
run: npm ci
- name: Run TypeScript compilation
working-directory: ./frontend
run: npm run make-i18n && tsc
run: npm run build
- name: Run tests and collect coverage
working-directory: ./frontend
run: npm run test:coverage

View File

@@ -40,7 +40,8 @@ jobs:
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
]')
else
json=$(jq -n -c '[
@@ -54,12 +55,10 @@ jobs:
ghcr_build_app:
name: Build App Image
runs-on: blacksmith-4vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
outputs:
# Since this job uses outputs it cannot use matrix
hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -85,24 +84,12 @@ jobs:
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
- name: Build app image
if: "github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --load
- name: Get hash in App Image
id: get_hash_in_app_image
run: |
# Run the build script in the app image
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
# Get the hash from the build script
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
echo "Hash from app image: $hash_from_app_image"
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
permissions:
contents: read
packages: write
@@ -128,22 +115,13 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# This is the one that saves the cache, the others set 'lookup-only: true'
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
@@ -188,61 +166,6 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
verify_hash_equivalence_in_runtime_and_app:
name: Verify Hash Equivalence in Runtime and Docker images
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime, ghcr_build_app]
strategy:
fail-fast: false
matrix:
base_image: ['nikolaik']
env:
BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Get hash in App Image
run: |
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV
- name: Get hash using code (development mode)
run: |
mkdir -p containers/runtime
poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV
- name: Compare hashes
run: |
echo "Hash from App Image: ${{ env.hash_from_app_image }}"
echo "Hash from Code: ${{ env.hash_from_code }}"
if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
echo "Hashes match!"
else
echo "Hashes do not match!"
exit 1
fi
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
@@ -274,25 +197,17 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -344,25 +259,17 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
cache: poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -389,7 +296,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -398,7 +305,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
needs: [test_runtime_root, test_runtime_oh]
steps:
- name: Some tests failed
run: |
@@ -423,6 +330,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
shell: bash
run: |
echo "updating PR description"
DOCKER_RUN_COMMAND="docker run -it --rm \

View File

@@ -21,10 +21,10 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 20
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 20
node-version: 22
- name: Install frontend dependencies
run: |
cd frontend
@@ -68,7 +68,7 @@ jobs:
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: 'pip'
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==3.7.0
- name: Fix python lint issues

View File

@@ -7,7 +7,7 @@ name: Lint
on:
push:
branches:
- main
- main
pull_request:
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
@@ -22,10 +22,10 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v4
- name: Install Node.js 20
- name: Install Node.js 22
uses: useblacksmith/setup-node@v5
with:
node-version: 20
node-version: 22
- name: Install dependencies
run: |
cd frontend
@@ -49,7 +49,7 @@ jobs:
uses: useblacksmith/setup-python@v6
with:
python-version: 3.12
cache: 'pip'
cache: "pip"
- name: Install pre-commit
run: pip install pre-commit==3.7.0
- name: Run pre-commit hooks

108
.github/workflows/npm-publish-ui.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: Publish OpenHands UI Package
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
paths:
- "openhands-ui/**"
- ".github/workflows/npm-publish-ui.yml"
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: npm-publish-ui
cancel-in-progress: false
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
outputs:
should-publish: ${{ steps.version-check.outputs.should-publish }}
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
- name: Check if version changed
id: version-check
run: |
# Get current version from package.json
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if package.json version changed in this commit
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
# Check if the version field specifically changed
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
echo "Version changed in package.json, will publish"
echo "should-publish=true" >> $GITHUB_OUTPUT
else
echo "package.json changed but version did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
else
echo "package.json did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build
- name: Check if package already exists on npm
id: npm-check
working-directory: ./openhands-ui
run: |
PACKAGE_NAME=$(jq -r .name package.json)
VERSION="${{ needs.check-version.outputs.current-version }}"
# Check if this version already exists on npm
if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
echo "Version $VERSION already exists on npm, skipping publish"
echo "already-exists=true" >> $GITHUB_OUTPUT
else
echo "Version $VERSION does not exist on npm, proceeding with publish"
echo "already-exists=false" >> $GITHUB_OUTPUT
fi
- name: Setup npm authentication
if: steps.npm-check.outputs.already-exists == 'false'
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Publish to npm
if: steps.npm-check.outputs.already-exists == 'false'
working-directory: ./openhands-ui
run: |
# The prepublishOnly script will run automatically and build the package
npm publish
echo "✅ Successfully published @openhands/ui@${{ needs.check-version.outputs.current-version }} to npm"

View File

@@ -15,7 +15,7 @@ jobs:
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
days-before-stale: 30
exempt-issue-labels: 'tracked'
exempt-issue-labels: 'roadmap'
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
days-before-close: 7

34
.github/workflows/ui-build.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Run UI Component Build
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
pull_request:
paths:
- 'openhands-ui/**'
- '.github/workflows/ui-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build

View File

@@ -0,0 +1,156 @@
# Workflow that validates the VSCode extension builds correctly
name: VSCode Extension CI
# * Always run on "main"
# * Run on PRs that have changes in the VSCode extension folder or this workflow
# * Run on tags that start with "ext-v"
on:
push:
branches:
- main
tags:
- 'ext-v*'
pull_request:
paths:
- 'openhands/integrations/vscode/**'
- 'build_vscode.py'
- '.github/workflows/vscode-extension-build.yml'
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
concurrency:
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
cancel-in-progress: true
jobs:
# Validate VSCode extension builds correctly
validate-vscode-extension:
name: Validate VSCode Extension Build
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: useblacksmith/setup-node@v5
with:
node-version: '22'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install VSCode extension dependencies
working-directory: ./openhands/integrations/vscode
run: npm ci
- name: Build VSCode extension via build_vscode.py
run: python build_vscode.py
env:
# Ensure we don't skip the build
SKIP_VSCODE_BUILD: ""
- name: Validate .vsix file
run: |
# Verify the .vsix was created and is valid
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
echo "✅ VSCode extension built successfully"
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
# Basic validation that the .vsix is a valid zip file
echo "🔍 Validating .vsix structure..."
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
echo "✅ VSCode extension validation passed"
else
echo "❌ VSCode extension build failed - .vsix not found"
exit 1
fi
- name: Upload VSCode extension artifact
uses: actions/upload-artifact@v4
with:
name: vscode-extension
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
retention-days: 7
- name: Comment on PR with artifact link
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Get file size for display
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
const stats = fs.statSync(vsixPath);
const fileSizeKB = Math.round(stats.size / 1024);
const comment = `## 🔧 VSCode Extension Built Successfully!
The VSCode extension has been built and is ready for testing.
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
**🚀 To install**:
1. Download the artifact from the workflow run above
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
3. Select the downloaded \`.vsix\` file
**✅ Tested with**: Node.js 22
**🔍 Validation**: File structure and integrity verified
---
*Built from commit ${{ github.sha }}*`;
// Check if we already commented on this PR and delete it
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.login === 'github-actions[bot]' &&
comment.body.includes('VSCode Extension Built Successfully')
);
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
});
}
// Create a new comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: comment
});
release:
name: Create GitHub Release
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: validate-vscode-extension
if: startsWith(github.ref, 'refs/tags/ext-v')
steps:
- name: Download .vsix artifact
uses: actions/download-artifact@v4
with:
name: vscode-extension
path: ./
- name: Create Release
uses: ncipollo/release-action@v1.16.0
with:
artifacts: "*.vsix"
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
allowUpdates: true

2
.gitignore vendored
View File

@@ -182,6 +182,8 @@ cython_debug/
.roo/rules
.cline/rules
.windsurf/rules
.repomix
repomix-output.txt
# evaluation
evaluation/evaluation_outputs

View File

@@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
@@ -60,6 +63,22 @@ Frontend:
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
VSCode Extension:
- Located in the `openhands/integrations/vscode` directory
- Setup: Run `npm install` in the extension directory
- Linting:
- Run linting with fixes: `npm run lint:fix`
- Check only: `npm run lint`
- Type checking: `npm run typecheck`
- Building:
- Compile TypeScript: `npm run compile`
- Package extension: `npm run package-vsix`
- Testing:
- Run tests: `npm run test`
- Development Best Practices:
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
- Pre-commit process runs both frontend and backend checks when committing extension changes
## Template for Github Pull Request
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.

View File

@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #frontend channel in our Slack
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
to gather consensus from our design team first.
#### Improving the agent

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.47-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.49-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.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.

View File

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

View File

@@ -1,156 +0,0 @@
# Resolver Runtime Refactoring Plan
## Task Overview
Refactor the resolver component to reuse setup.py functions for runtime initialization, connection, and completion instead of reinventing the wheel.
## Repository Cloning Patterns Analysis
### Repository Cloning Patterns Across OpenHands Entry Points
#### 1. **Resolver (issue_resolver.py)** - DIFFERENT PATTERN (Legacy)
```python
# Step 1: Clone to separate location
subprocess.check_output(['git', 'clone', url, f'{output_dir}/repo'])
# Step 2: Later, copy repo to workspace
shutil.copytree(os.path.join(self.output_dir, 'repo'), self.workspace_base)
# Step 3: Create and connect runtime
runtime = create_runtime(config)
await runtime.connect()
# Step 4: Initialize runtime (git config, setup scripts)
self.initialize_runtime(runtime)
```
#### 2. **Main.py** - STANDARD PATTERN
```python
# Step 1: Create and connect runtime
runtime = create_runtime(config)
await runtime.connect()
# Step 2: Clone directly into runtime workspace + setup
repo_directory = initialize_repository_for_runtime(runtime, selected_repository)
```
#### 3. **Server/Session** - STANDARD PATTERN
```python
# Step 1: Create and connect runtime
# Step 2: Clone directly into runtime workspace
await runtime.clone_or_init_repo(tokens, repo, branch)
# Step 3: Run setup scripts
await runtime.maybe_run_setup_script()
await runtime.maybe_setup_git_hooks()
```
#### 4. **Setup.py's initialize_repository_for_runtime()** - STANDARD PATTERN
```python
# Calls runtime.clone_or_init_repo() + setup scripts
repo_directory = runtime.clone_or_init_repo(tokens, repo, branch)
runtime.maybe_run_setup_script()
runtime.maybe_setup_git_hooks()
```
### The Issue
The **resolver is the odd one out** - it uses a 2-step process (clone to temp location, then copy to workspace) due to **legacy reasons** (it was originally developed as a separate app built on OH, not a component of OH). All other entry points use the standard pattern (clone directly into runtime workspace).
## Current State Analysis
### ✅ What Resolver Already Does Right:
- [x] Uses `create_runtime()` from setup.py for runtime creation
### ❌ What Needs to be Fixed:
- [ ] **Resolver uses legacy 2-step cloning instead of standard runtime.clone_or_init_repo()**
- [ ] Resolver has custom `initialize_runtime()` method that duplicates setup.py logic
- [ ] Resolver has custom `complete_runtime()` method with no setup.py equivalent
- [ ] Resolver doesn't follow proper runtime cleanup patterns like main.py
- [ ] Runtime connection pattern is inconsistent across codebase
## Refactoring Steps
### Phase 1: Fix Repository Cloning Pattern (PRIORITY)
**Goal**: Make resolver use the same repository cloning pattern as all other OpenHands entry points.
- [ ] **Step 1.1**: Replace resolver's legacy 2-step cloning with standard pattern
- Remove `subprocess.check_output(['git', 'clone', ...])` from `resolve_issue()`
- Remove `shutil.copytree()` from `process_issue()`
- Use `initialize_repository_for_runtime()` instead
- This will clone directly into runtime workspace AND run setup scripts
- [ ] **Step 1.2**: Update resolver workflow to match standard pattern
- Create and connect runtime first
- Then call `initialize_repository_for_runtime()` for cloning + setup
- Remove the manual repo copying step entirely
- Ensure base_commit is still captured correctly
### Phase 2: Refactor Runtime Initialization and Completion
**Goal**: Remove code duplication between resolver and setup.py for runtime operations.
- [ ] **Step 2.1**: Create missing functions in setup.py
- Create `setup_runtime_environment()` for git config and platform-specific setup
- Create `complete_runtime_session()` for git patch generation
- Create `cleanup_runtime()` for proper resource cleanup
- [ ] **Step 2.2**: Replace resolver's `initialize_runtime()`
- Use setup.py's `setup_runtime_environment()` instead
- Remove duplicate git configuration code
- Maintain platform-specific behavior (GitLab CI)
- [ ] **Step 2.3**: Replace resolver's `complete_runtime()`
- Use setup.py's `complete_runtime_session()` instead
- Move git patch generation logic to setup.py
- Ensure return values match resolver's expectations
- [ ] **Step 2.4**: Add proper runtime cleanup to resolver
- Use setup.py's `cleanup_runtime()` function
- Ensure resources are properly released in try/finally blocks
### Phase 3: Testing and Validation
- [ ] **Step 3.1**: Test resolver functionality with refactored code
- Verify git operations work correctly
- Verify setup scripts are executed
- Verify git hooks are set up
- [ ] **Step 3.2**: Test runtime lifecycle (create → connect → clone → initialize → complete → cleanup)
- Ensure no resource leaks
- Verify proper error handling
- [ ] **Step 3.3**: Verify resolver output remains consistent
- Git patches are generated correctly
- Issue resolution works as before
- No regression in functionality
### Phase 4: Code Quality and Documentation
- [ ] **Step 4.1**: Add proper documentation to new setup.py functions
- Document parameters and return values
- Add usage examples
- Document platform-specific behavior
- [ ] **Step 4.2**: Remove obsolete code from resolver
- Delete old `initialize_runtime()` method
- Delete old `complete_runtime()` method
- Clean up imports and unused code
- [ ] **Step 4.3**: Update any other components that might benefit from these functions
- Check if other entry points could use the same patterns
- Ensure consistency across the codebase
## Success Criteria
- [ ] **Resolver uses standard repository cloning pattern (runtime.clone_or_init_repo)**
- [ ] Resolver uses setup.py functions for all runtime operations
- [ ] No code duplication between resolver and setup.py
- [ ] Proper runtime lifecycle management (connect → initialize → complete → cleanup)
- [ ] All existing resolver functionality preserved
- [ ] Consistent patterns across all OpenHands entry points
- [ ] Proper error handling and resource cleanup
## Files to Modify
1. `/openhands/core/setup.py` - Add new runtime management functions
2. `/openhands/resolver/issue_resolver.py` - Refactor to use setup.py functions
3. Any tests related to resolver functionality
## Risk Mitigation
- Maintain backward compatibility during refactoring
- Test thoroughly before removing old code
- Keep git patch generation logic identical to avoid breaking issue resolution
- Ensure platform-specific behavior (GitLab CI) is preserved

114
build_vscode.py Normal file
View File

@@ -0,0 +1,114 @@
import os
import pathlib
import subprocess
# This script is intended to be run by Poetry during the build process.
# Define the expected name of the .vsix file based on the extension's package.json
# This should match the name and version in openhands-vscode/package.json
EXTENSION_NAME = 'openhands-vscode'
EXTENSION_VERSION = '0.0.1'
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
# Paths
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
def check_node_version():
"""Check if Node.js version is sufficient for building the extension."""
try:
result = subprocess.run(
['node', '--version'], capture_output=True, text=True, check=True
)
version_str = result.stdout.strip()
# Extract major version number (e.g., "v12.22.9" -> 12)
major_version = int(version_str.lstrip('v').split('.')[0])
return major_version >= 18 # Align with frontend actual usage (18.20.1)
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
return False
def build_vscode_extension():
"""Builds the VS Code extension."""
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
# Check if VSCode extension build is disabled via environment variable
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
if vsix_path.exists():
print(f'--- Using existing VS Code extension: {vsix_path} ---')
else:
print('--- No pre-built VS Code extension found ---')
return
# Check Node.js version - if insufficient, use pre-built extension as fallback
if not check_node_version():
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
print('--- Using pre-built extension if available ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
else:
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
return
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
try:
# Ensure npm dependencies are installed
print('--- Running npm install for VS Code extension ---')
subprocess.run(
['npm', 'install'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Package the extension
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
subprocess.run(
['npm', 'run', 'package-vsix'],
cwd=VSCODE_EXTENSION_DIR,
check=True,
shell=os.name == 'nt',
)
# Verify the generated .vsix file exists
if not vsix_path.exists():
raise FileNotFoundError(
f'VS Code extension package not found after build: {vsix_path}'
)
print(f'--- VS Code extension built successfully: {vsix_path} ---')
except subprocess.CalledProcessError as e:
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
print('--- Continuing without building extension ---')
if not vsix_path.exists():
print('--- Warning: No pre-built VS Code extension found ---')
print('--- VS Code extension will not be available ---')
def build(setup_kwargs):
"""
This function is called by Poetry during the build process.
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
"""
print('--- Running custom Poetry build script (build_vscode.py) ---')
# Build the VS Code extension and place the .vsix file
build_vscode_extension()
# Poetry will handle including files based on pyproject.toml `include` patterns.
# Ensure openhands/integrations/vscode/*.vsix is included there.
print('--- Custom Poetry build script (build_vscode.py) finished ---')
if __name__ == '__main__':
print('Running build_vscode.py directly for testing VS Code extension packaging...')
build_vscode_extension()
print('Direct execution of build_vscode.py finished.')

View File

@@ -18,9 +18,6 @@
# Cache directory path
#cache_dir = "/tmp/cache"
# Reasoning effort for o1 models (low, medium, high, or not set)
#reasoning_effort = "medium"
# Debugging enabled
#debug = false
@@ -49,6 +46,9 @@
# Maximum file size for uploads, in megabytes
#file_uploads_max_file_size_mb = 0
# Enable the browser environment
#enable_browser = true
# Maximum budget per task, 0.0 means no limit
#max_budget_per_task = 0.0
@@ -116,6 +116,9 @@ api_key = ""
# API version
#api_version = ""
# Reasoning effort for OpenAI o-series models (low, medium, high, or not set)
#reasoning_effort = "medium"
# Cost per input token
#input_cost_per_token = 0.0
@@ -226,6 +229,7 @@ model = "gpt-4o"
[agent]
# Whether the browsing tool is enabled
# Note: when this is set to true, enable_browser in the core config must also be true
enable_browsing = true
# Whether the LLM draft editor is enabled

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
@@ -45,6 +45,7 @@ ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=/.openhands
ENV INIT_GIT_IN_EMPTY_WORKSPACE=1
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE

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.47-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.49-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

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.47-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik}
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:

View File

@@ -8,6 +8,12 @@ description: This page outlines all available configuration options for OpenHand
In GUI Mode, any settings applied through the Settings UI will take precedence.
</Note>
## Location of the `config.toml` File
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
located in the same directory from which the command is run. Alternatively, you may use the `--config-file` option to
specify a different path to the `config.toml` file.
## Core Configuration
The core configuration options are defined in the `[core]` section of the `config.toml` file.

View File

@@ -33,6 +33,45 @@ pip install openhands-ai
uvx --python 3.12 --from openhands-ai openhands
```
<AccordionGroup>
<Accordion title="Create shell aliases for easy access across environments">
Add the following to your shell configuration file (`.bashrc`, `.zshrc`, etc.):
```bash
# Add OpenHands aliases
alias openhands="uvx --python 3.12 --from openhands-ai openhands"
alias oh="uvx --python 3.12 --from openhands-ai openhands"
```
After adding these lines, reload your shell configuration with `source ~/.bashrc` or `source ~/.zshrc` (depending on your shell).
</Accordion>
<Accordion title="Install OpenHands in home directory without global installation">
You can install OpenHands in a virtual environment in your home directory using `uv`:
```bash
# Create a virtual environment in your home directory
cd ~
uv venv .openhands-venv --python 3.12
# Install OpenHands in the virtual environment
uv pip install -t ~/.openhands-venv/lib/python3.12/site-packages openhands-ai
# Add the bin directory to your PATH in your shell configuration file
echo 'export PATH="$PATH:$HOME/.openhands-venv/bin"' >> ~/.bashrc # or ~/.zshrc
# Reload your shell configuration
source ~/.bashrc # or source ~/.zshrc
```
</Accordion>
</AccordionGroup>
2. Launch an interactive OpenHands conversation from the command line:
```bash
openhands
@@ -64,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +112,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
python -m openhands.cli.main --override-cli-mode true
```

View File

@@ -122,17 +122,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
</Accordion>
</AccordionGroup>
#### BitBucket Setup (Coming soon ...)
#### BitBucket Setup
<AccordionGroup>
<Accordion title="Setting Up a BitBucket Password">
1. **Generate an App Password**:
- On BitBucket, go to Personal Settings > App Password.
- Create a new password with the following scopes:
- `repository: read`
- `account`: `read`
- `repository: write`
- `pull requests: read`
- `pull requests: write`
- `issues: read`
- `issues: write`
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
2. **Enter Token in OpenHands**:

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.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
-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.47 \
docker.all-hands.dev/all-hands-ai/openhands:0.49 \
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.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
2. Wait until the server is running (see log below):
```
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.49
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
@@ -175,6 +175,27 @@ vllm serve mistralai/Devstral-Small-2505 \
--enable-prefix-caching
```
If you are interested in further improved inference speed, you can also try Snowflake's version
of vLLM, [ArcticInference](https://www.snowflake.com/en/engineering-blog/fast-speculative-decoding-vllm-arctic/),
which can achieve up to 2x speedup in some cases.
1. Install the Arctic Inference library that automatically patches vLLM:
```bash
pip install git+https://github.com/snowflakedb/ArcticInference.git
```
2. Run the launch command with speculative decoding enabled:
```bash
vllm serve mistralai/Devstral-Small-2505 \
--host 0.0.0.0 --port 8000 \
--api-key mykey \
--tensor-parallel-size 2 \
--served-model-name Devstral-Small-2505 \
--speculative-config '{"method": "suffix"}'
```
### Run OpenHands (Alternative Backends)
#### Using Docker

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.47-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.49-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.47
docker.all-hands.dev/all-hands-ai/openhands:0.49
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.

View File

@@ -24,3 +24,12 @@ General microagent file example for organization `Great-Co` located inside the `
```
For GitLab organizations, the same microagent would be located inside the `openhands-config` repository.
## User Microagents When Running Openhands on Your Own
<Note>
This works with CLI, headless and development modes. It does not work out of the box when running OpenHands using the docker command.
</Note>
When running OpenHands on your own, you can place microagents in the `~/.openhands/microagents` folder on your local
system and OpenHands will always load it for all your conversations.

View File

@@ -38,6 +38,21 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### Internal Server Error. Ports are not available
**Description**
When running on Windows, the error `Internal Server Error ("ports are not available: exposing port TCP
...: bind: An attempt was made to access a socket in a
way forbidden by its access permissions.")` is encountered.
**Resolution**
* Run the following command in PowerShell, as Administrator to reset the NAT service and release the ports:
```
Restart-Service -Name "winnat"
```
### Unable to access VS Code tab via local IP
**Description**

View File

@@ -101,13 +101,14 @@ The OpenHands evaluation harness supports a wide variety of benchmarks across [s
- SWE-Bench: [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench)
- HumanEvalFix: [`evaluation/benchmarks/humanevalfix`](./benchmarks/humanevalfix)
- BIRD: [`evaluation/benchmarks/bird`](./benchmarks/bird)
- BioCoder: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
- ML-Bench: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- APIBench: [`evaluation/benchmarks/gorilla`](./benchmarks/gorilla/)
- ToolQA: [`evaluation/benchmarks/toolqa`](./benchmarks/toolqa/)
- AiderBench: [`evaluation/benchmarks/aider_bench`](./benchmarks/aider_bench/)
- Commit0: [`evaluation/benchmarks/commit0_bench`](./benchmarks/commit0_bench/)
- DiscoveryBench: [`evaluation/benchmarks/discoverybench`](./benchmarks/discoverybench/)
- TerminalBench: [`evaluation/benchmarks/terminal_bench`](./benchmarks/terminal_bench)
### Web Browsing

View File

@@ -0,0 +1,45 @@
# **Localization Evaluation for SWE-Bench**
This folder implements localization evaluation at both file and function levels to complementing the assessment of agent inference on [SWE-Bench](https://www.swebench.com/).
## **1. Environment Setup**
- Python env: [Install python environment](../../../README.md#development-environment)
- LLM config: [Configure LLM config](../../../README.md#configure-openhands-and-your-llm)
## **2. Inference & Evaluation**
- Inference and evaluation follow the original `run_infer.sh` and `run_eval.sh` implementation
- You may refer to instructions at [README.md](../README.md) for running inference and evaluation on SWE-Bench
## **3. Localization Evaluation**
- Localization evaluation computes two-level localization accuracy, while also considers task success as an additional metric for overall evaluation:
- **File Localization Accuracy:** Accuracy of correctly localizing the target file
- **Function Localization Accuracy:** Accuracy of correctly localizing the target function
- **Resolve Rate** (will be auto-skipped if missing): Success rate of whether tasks are successfully resolved
- **File Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Function Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Task success efficiency:** Average number of iterations taken to resolve the task
- **Resource efficiency:** the API expenditure of the agent running inference on SWE-Bench instances
- Run localization evaluation
- Format:
```bash
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh [infer-dir] [split] [dataset] [max-infer-turn] [align-with-max]
```
- `infer-dir`: inference directory containing inference outputs
- `split`: SWE-Bench dataset split to use
- `dataset`: SWE-Bench dataset name
- `max-infer-turn`: the maximum number of iterations the agent took to run inference
- `align-with-max`: whether to align failure indices (e.g., incorrect localization, unresolved tasks) with `max_iter`
- Example:
```bash
# Example
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh \
--infer-dir ./evaluation/evaluation_outputs/outputs/princeton-nlp__SWE-bench_Verified-test/CodeActAgent/gpt_4o_100_N \
--split test \
--dataset princeton-nlp/SWE-bench_Verified \
--max-infer-turn 100 \
--align-with-max true
```
- Localization evaluation results will be automatically saved to `[infer-dir]/loc_eval`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -109,9 +109,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
template_name = 'swt.j2'
elif mode == 'swe':
if 'claude' in llm_model:
template_name = 'swe_claude.j2'
elif 'gemini' in llm_model:
template_name = 'swe_gemini.j2'
template_name = 'swe_default.j2'
elif 'gpt-4.1' in llm_model:
template_name = 'swe_gpt4.j2'
else:

View File

@@ -0,0 +1,227 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Function to display usage information
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --infer-dir DIR Directory containing model inference outputs"
echo " --split SPLIT SWE-Bench dataset split selection"
echo " --dataset DATASET Dataset name"
echo " --max-infer-turn NUM Max number of turns for coding agent"
echo " --align-with-max BOOL Align failed instance indices with max iteration (true/false)"
echo " -h, --help Display this help message"
echo ""
echo "Example:"
echo " $0 --infer-dir ./inference_outputs --split test --align-with-max false"
}
# Check if no arguments were provided
if [ $# -eq 0 ]; then
usage
exit 1
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--infer-dir)
INFER_DIR="$2"
shift 2
;;
--split)
SPLIT="$2"
shift 2
;;
--dataset)
DATASET="$2"
shift 2
;;
--max-infer-turn)
MAX_TURN="$2"
shift 2
;;
--align-with-max)
ALIGN_WITH_MAX="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
# Check for required arguments (only INFER_DIR is required)
if [ -z "$INFER_DIR" ]; then
echo "Error: Missing required arguments (--infer-dir is required)"
usage
exit 1
fi
# Set defaults for optional arguments if not provided
if [ -z "$SPLIT" ]; then
SPLIT="test"
echo "Split not specified, using default: $SPLIT"
fi
if [ -z "$DATASET" ]; then
DATASET="princeton-nlp/SWE-bench_Verified"
echo "Dataset not specified, using default: $DATASET"
fi
if [ -z "$MAX_TURN" ]; then
MAX_TURN=20
echo "Max inference turn not specified, using default: $MAX_TURN"
fi
if [ -z "$ALIGN_WITH_MAX" ]; then
ALIGN_WITH_MAX="true"
echo "Align with max not specified, using default: $ALIGN_WITH_MAX"
fi
# Validate align-with-max value
if [ "$ALIGN_WITH_MAX" != "true" ] && [ "$ALIGN_WITH_MAX" != "false" ]; then
print_error "Invalid value for --align-with-max: $ALIGN_WITH_MAX. Must be 'true' or 'false'"
exit 1
fi
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_header() {
echo -e "${BLUE}[TASK]${NC} $1"
}
# Check if Python is available
print_header "Checking Python installation..."
if ! command -v python3 &> /dev/null; then
if ! command -v python &> /dev/null; then
print_error "Python is not installed or not in PATH"
exit 1
else
PYTHON_CMD="python"
print_status "Using python command"
fi
else
PYTHON_CMD="python3"
print_status "Using python3 command"
fi
# Check if the Python script exists
SCRIPT_NAME="./evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py"
if [ ! -f "$SCRIPT_NAME" ]; then
print_error "Python script '$SCRIPT_NAME' not found in current directory"
print_warning "Make sure the Python script is in the same directory as this bash script"
exit 1
fi
# Check if required directories exist
print_header "Validating directories..."
if [ ! -d "$INFER_DIR" ]; then
print_error "Inference directory not found: $INFER_DIR"
exit 1
fi
# Evaluation outputs
EVAL_DIR="$INFER_DIR/eval_outputs"
# Display configuration
print_header "Starting Localization Evaluation with the following configuration:"
echo " Inference Directory: $INFER_DIR"
if [ -d "$EVAL_DIR" ]; then
echo " Evaluation Directory: $EVAL_DIR"
else
echo " Evaluation Directory: None (evaluation outputs doesn't exist)"
fi
echo " Output Directory: $INFER_DIR/loc_eval"
echo " Split: $SPLIT"
echo " Dataset: $DATASET"
echo " Max Turns: $MAX_TURN"
echo " Align with Max: $ALIGN_WITH_MAX"
echo " Python Command: $PYTHON_CMD"
echo ""
# Check Python dependencies (optional check)
print_header "Checking Python dependencies..."
$PYTHON_CMD -c "
import sys
required_modules = ['pandas', 'json', 'os', 'argparse', 'collections']
missing_modules = []
for module in required_modules:
try:
__import__(module)
except ImportError:
missing_modules.append(module)
if missing_modules:
print(f'Missing required modules: {missing_modules}')
sys.exit(1)
else:
print('All basic dependencies are available')
" || {
print_error "Some Python dependencies are missing"
print_warning "Please install required packages: pip install pandas"
exit 1
}
# Create log directory if doesn't exists
mkdir -p "$INFER_DIR/loc_eval"
# Set up logging
LOG_FILE="$INFER_DIR/loc_eval/loc_evaluation_$(date +%Y%m%d_%H%M%S).log"
print_status "Logging output to: $LOG_FILE"
# Build the command
CMD_ARGS="\"$SCRIPT_NAME\" \
--infer-dir \"$INFER_DIR\" \
--split \"$SPLIT\" \
--dataset \"$DATASET\" \
--max-infer-turn \"$MAX_TURN\" \
--align-with-max \"$ALIGN_WITH_MAX\""
# Run the Python script
print_header "Running localization evaluation..."
eval "$PYTHON_CMD $CMD_ARGS" 2>&1 | tee "$LOG_FILE"
# Check if the script ran successfully
if [ ${PIPESTATUS[0]} -eq 0 ]; then
print_status "Localization evaluation completed successfully!"
print_status "Results saved to: $INFER_DIR/loc_eval"
print_status "Log file: $LOG_FILE"
# Display summary if results exist
if [ -f "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json" ]; then
print_header "Evaluation Summary:"
cat "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json"
echo
fi
else
print_error "Localization evaluation failed!"
print_warning "Check the log file for details: $LOG_FILE"
exit 1
fi

View File

@@ -0,0 +1,31 @@
# Terminal-Bench Evaluation on OpenHands
Terminal-Bench has its own evaluation harness that is very different from OpenHands'. We
implemented [OpenHands agent](https://github.com/laude-institute/terminal-bench/tree/main/terminal_bench/agents/installed_agents/openhands) using OpenHands local runtime
inside terminal-bench framework. Hereby we introduce how to use the terminal-bench
harness to evaluate OpenHands.
## Installation
Terminal-bench ships a CLI tool to manage tasks and run evaluation.
Please follow official [Installation Doc](https://www.tbench.ai/docs/installation). You could also clone terminal-bench [source code](https://github.com/laude-institute/terminal-bench) and use `uv run tb` CLI.
## Evaluation
Please see [Terminal-Bench Leaderboard](https://www.tbench.ai/leaderboard) for the latest
instruction on benchmarking guidance. The dataset might evolve.
Sample command:
```bash
export LLM_BASE_URL=<optional base url>
export LLM_API_KEY=<llm key>
tb run \
--dataset-name terminal-bench-core \
--dataset-version 0.1.1 \
--agent openhands \
--model <model> \
--cleanup
```
You could run `tb --help` or `tb run --help` to learn more about their CLI.

View File

@@ -13,8 +13,9 @@
"plugin:react-hooks/recommended",
"plugin:@tanstack/query/recommended",
],
"plugins": ["prettier", "unused-imports"],
"plugins": ["prettier", "unused-imports", "i18next"],
"rules": {
"i18next/no-literal-string": "error",
"unused-imports/no-unused-imports": "error",
"prettier/prettier": ["error"],
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871

View File

@@ -1,7 +1,7 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npm run lint
npm run check-translation-completeness
npx lint-staged

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

@@ -44,4 +44,64 @@ describe("AuthModal", () => {
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
);
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
expect(termsSection).toHaveTextContent("COMMON$AND");
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
// Check Terms of Service link
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toBeInTheDocument();
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveClass("underline", "hover:text-primary");
// Check Privacy Policy link
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute(
"href",
"https://www.all-hands.dev/privacy",
);
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
// Verify that both links are within the terms section
expect(termsSection).toContainElement(tosLink);
expect(termsSection).toContainElement(privacyLink);
});
it("should open Terms of Service link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toHaveAttribute("target", "_blank");
});
it("should open Privacy Policy link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -0,0 +1,167 @@
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";
import { I18nKey } from "#/i18n/declaration";
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"]
}),
}));
// Mock the useTranslation hook
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
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

@@ -27,9 +27,9 @@ vi.mock("react-i18next", async () => {
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CONVERSATION$CREATED": "Created",
"CONVERSATION$AGO": "ago",
"CONVERSATION$UPDATED": "Updated"
CONVERSATION$CREATED: "Created",
CONVERSATION$AGO: "ago",
CONVERSATION$UPDATED: "Updated",
};
return translations[key] || key;
},
@@ -82,7 +82,9 @@ describe("ConversationCard", () => {
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
const timeRegex = new RegExp(
formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
);
expect(card).toHaveTextContent(timeRegex);
});
@@ -108,7 +110,11 @@ describe("ConversationCard", () => {
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -173,7 +179,11 @@ describe("ConversationCard", () => {
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository="org/selectedRepository"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);

View File

@@ -295,4 +295,521 @@ describe("ConversationPanel", () => {
const newCards = await screen.findAllByTestId("conversation-card");
expect(newCards).toHaveLength(3);
});
it("should cancel stopping a conversation", async () => {
const user = userEvent.setup();
// Create mock data with a RUNNING conversation
const mockRunningConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Stop button should be available for RUNNING conversation
const stopButton = screen.getByTestId("stop-button");
expect(stopButton).toBeInTheDocument();
// Click the stop button
await user.click(stopButton);
// Cancel the stopping action
const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton);
expect(
screen.queryByRole("button", { name: /cancel/i }),
).not.toBeInTheDocument();
// Ensure the conversation status hasn't changed
const updatedCards = await screen.findAllByTestId("conversation-card");
expect(updatedCards).toHaveLength(3);
});
it("should stop a conversation", async () => {
const user = userEvent.setup();
const mockData: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData);
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
stopConversationSpy.mockImplementation(async (id: string) => {
const conversation = mockData.find((conv) => conv.conversation_id === id);
if (conversation) {
conversation.status = "STOPPED";
return conversation;
}
return null;
});
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(2);
// Click ellipsis on the first card (RUNNING status)
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const stopButton = screen.getByTestId("stop-button");
// Click the stop button
await user.click(stopButton);
// Confirm the stopping action
const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton);
expect(
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Verify the API was called
expect(stopConversationSpy).toHaveBeenCalledWith("1");
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
});
it("should only show stop button for STARTING or RUNNING conversations", async () => {
const user = userEvent.setup();
const mockMixedStatusConversations: Conversation[] = [
{
conversation_id: "1",
title: "Running Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-01T12:00:00Z",
created_at: "2021-10-01T12:00:00Z",
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "2",
title: "Starting Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-02T12:00:00Z",
created_at: "2021-10-02T12:00:00Z",
status: "STARTING" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
{
conversation_id: "3",
title: "Stopped Conversation",
selected_repository: null,
git_provider: null,
selected_branch: null,
last_updated_at: "2021-10-03T12:00:00Z",
created_at: "2021-10-03T12:00:00Z",
status: "STOPPED" as const,
runtime_status: null,
url: null,
session_api_key: null,
},
];
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Test RUNNING conversation - should show stop button
const runningEllipsisButton = within(cards[0]).getByTestId(
"ellipsis-button",
);
await user.click(runningEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STARTING conversation - should show stop button
const startingEllipsisButton = within(cards[1]).getByTestId(
"ellipsis-button",
);
await user.click(startingEllipsisButton);
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
// Click outside to close the menu
await user.click(document.body);
// Test STOPPED conversation - should NOT show stop button
const stoppedEllipsisButton = within(cards[2]).getByTestId(
"ellipsis-button",
);
await user.click(stoppedEllipsisButton);
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
});
it("should show edit button in context menu", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Should find input field instead of title text
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
expect(titleInput).toBeInTheDocument();
expect(titleInput.tagName).toBe("INPUT");
expect(titleInput).toHaveValue("Conversation 1");
expect(titleInput).toHaveFocus();
});
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
// Mock the toast function
const mockToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: mockToast,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
// Blur the input to save
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title and press Enter
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Title Updated via Enter");
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with extra whitespace
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, " Trimmed Title ");
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Clear the title completely
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.tab();
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockRejectedValue(new Error("API Error"));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Failed Update");
await user.tab();
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
// Wait for error handling
await waitFor(() => {
expect(updateConversationSpy).toHaveBeenCalled();
});
});
it("should close context menu when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Verify context menu is open
const contextMenu = screen.getByTestId("context-menu");
expect(contextMenu).toBeInTheDocument();
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Verify context menu is closed
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with special characters
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Special @#$%^&*()_+ Characters");
await user.tab();
// Verify API call was made with special characters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
});
});

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

@@ -119,18 +119,48 @@ describe("RepoConnector", () => {
expect(launchButton).toBeEnabled();
});
it("should render the 'add git(hub|lab) repos' links if saas mode", async () => {
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
renderRepoConnector();
await screen.findByText("HOME$ADD_GITHUB_REPOS");
});
it("should not render the 'add github repos' link if github provider is not set", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
getConfiSpy.mockResolvedValue({
APP_MODE: "saas",
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "some-token",
github: null,
},
});
renderRepoConnector();
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
});
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return the APP_MODE
@@ -176,9 +206,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} />, {
@@ -252,8 +257,6 @@ describe("RepositorySelectionForm", () => {
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(
MOCK_SEARCH_REPOS[0].full_name,
);
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
});
});

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,84 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderWithProviders } from "test-utils";
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
import OpenHands from "#/api/open-hands";
import { AgentState } from "#/types/agent-state";
vi.mock("react-redux", async () => {
const actual = await vi.importActual("react-redux");
return {
...actual,
useDispatch: () => vi.fn(),
useSelector: () => ({
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
}),
};
});
describe("MicroagentsModal - Refresh Button", () => {
const mockOnClose = vi.fn();
const conversationId = "test-conversation-id";
const defaultProps = {
onClose: mockOnClose,
conversationId,
};
const mockMicroagents = [
{
name: "Test Agent 1",
type: "repo" as const,
triggers: ["test", "example"],
content: "This is test content for agent 1",
},
{
name: "Test Agent 2",
type: "knowledge" as const,
triggers: ["help", "support"],
content: "This is test content for agent 2",
},
];
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
// Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
microagents: mockMicroagents,
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Refresh Button Rendering", () => {
it("should render the refresh button with correct text and test ID", () => {
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshButton = screen.getByTestId("refresh-microagents");
expect(refreshButton).toBeInTheDocument();
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
});
});
describe("Refresh Button Functionality", () => {
it("should call refetch when refresh button is clicked", async () => {
const user = userEvent.setup();
renderWithProviders(<MicroagentsModal {...defaultProps} />);
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
const refreshButton = screen.getByTestId("refresh-microagents");
await user.click(refreshButton);
expect(refreshSpy).toHaveBeenCalledTimes(1);
});
});
});

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

@@ -21,7 +21,12 @@ describe("UserActions", () => {
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -57,15 +62,102 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
test("logout button is always enabled", async () => {
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
// Context menu should NOT appear because user is undefined
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
expect(onLogoutMock).toHaveBeenCalledOnce();
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should NOT be able to access logout when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should not be accessible because context menu doesn't appear
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should handle user prop changing from undefined to defined", () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Initially no user - context menu shouldn't work
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
// Click to open menu
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should disappear
rerender(<UserActions onLogout={onLogoutMock} />);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
isLoading={true}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should still appear even when loading
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,140 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
// Mock the useConfig hook
vi.mock("#/hooks/query/use-config", () => ({
useConfig: vi.fn(),
}));
// Mock the useConversationId hook
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: "test-conversation-id" }),
}));
describe("useFeedbackExists", () => {
let queryClient: QueryClient;
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
mockCheckFeedbackExists.mockClear();
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("should not call API when APP_MODE is not saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "oss" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should call API when APP_MODE is saas", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
mockCheckFeedbackExists.mockResolvedValue({
exists: true,
rating: 5,
reason: "Great job!",
});
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for the query to complete
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was called
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
"test-conversation-id",
123,
);
// Verify that the data is returned
expect(result.current.data).toEqual({
exists: true,
rating: 5,
reason: "Great job!",
});
});
it("should not call API when eventId is not provided", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(undefined), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
it("should not call API when config is not loaded yet", async () => {
const { useConfig } = await import("#/hooks/query/use-config");
vi.mocked(useConfig).mockReturnValue({
data: undefined,
isLoading: true,
error: null,
} as ReturnType<typeof useConfig>);
const { result } = renderHook(() => useFeedbackExists(123), {
wrapper,
});
// Wait for any potential async operations
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Verify that the API was not called
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
// Verify that the query is disabled
expect(result.current.data).toBeUndefined();
});
});

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,8 +1,8 @@
import { render, screen, within } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen from "#/routes/settings";
import { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import OpenHands from "#/api/open-hands";
// Mock the i18next hook
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
});
describe("Settings Screen", () => {
const { handleLogoutMock } = vi.hoisted(() => ({
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
handleLogoutMock: vi.fn(),
mockQueryClient: (() => {
const { QueryClient } = require("@tanstack/react-query");
return new QueryClient();
})(),
}));
vi.mock("#/hooks/use-app-logout", () => ({
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
}));
vi.mock("#/query-client-config", () => ({
queryClient: mockQueryClient,
}));
const RouterStub = createRoutesStub([
{
Component: SettingsScreen,
// @ts-expect-error - custom loader
clientLoader,
path: "/settings",
children: [
{
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
path: "/settings/app",
},
{
Component: () => <div data-testid="credits-settings-screen" />,
path: "/settings/credits",
Component: () => <div data-testid="billing-settings-screen" />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="api-keys-settings-screen" />,
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
},
]);
const renderSettingsScreen = (path = "/settings") => {
const queryClient = new QueryClient();
return render(<RouterStub initialEntries={[path]} />, {
const renderSettingsScreen = (path = "/settings") =>
render(<RouterStub initialEntries={[path]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<QueryClientProvider client={mockQueryClient}>
{children}
</QueryClientProvider>
),
});
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should render the saas navbar", async () => {
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
const sectionsToInclude = [
"integrations",
"application",
"credits",
"credits", // The nav item shows "credits" text but routes to /billing
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should not be able to access oss-restricted routes in oss", async () => {
it("should not be able to access saas-only routes in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return app mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
const { rerender } = renderSettingsScreen("/settings/credits");
// Clear any existing query data
mockQueryClient.clear();
// In OSS mode, accessing restricted routes should redirect to /settings
// Since createRoutesStub doesn't handle clientLoader redirects properly,
// we test that the correct navbar is shown (OSS navbar) and that
// the restricted route components are not rendered when accessing /settings
renderSettingsScreen("/settings");
// Verify we're in OSS mode by checking the navbar
const navbar = await screen.findByTestId("settings-navbar");
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
expect(
screen.queryByTestId("credits-settings-screen"),
within(navbar).queryByText("credits", { exact: false }),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
// Verify the LLM settings screen is shown
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
expect(
screen.queryByTestId("billing-settings-screen"),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings"]} />);
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
getConfigSpy.mockRestore();
});
it.todo("should not be able to access saas-restricted routes in saas");
it.todo("should not be able to access oss-only routes in saas mode");
});

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import ActionType from "#/types/action-type";
import { ActionMessage } from "#/types/message";
// Mock the store and actions
const mockDispatch = vi.fn();
const mockAppendInput = vi.fn();
const mockAppendJupyterInput = vi.fn();
vi.mock("#/store", () => ({
default: {
dispatch: mockDispatch,
},
}));
vi.mock("#/state/command-slice", () => ({
appendInput: mockAppendInput,
}));
vi.mock("#/state/jupyter-slice", () => ({
appendJupyterInput: mockAppendJupyterInput,
}));
describe("handleActionMessage", () => {
beforeEach(() => {
// Clear all mocks before each test
vi.clearAllMocks();
});
it("should handle RUN actions by adding input to terminal", async () => {
const { handleActionMessage } = await import("#/services/actions");
const runAction: ActionMessage = {
id: 1,
source: "agent",
action: ActionType.RUN,
args: {
command: "ls -la",
},
message: "Running command: ls -la",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(runAction);
// Check that appendInput was called with the command
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
});
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
const { handleActionMessage } = await import("#/services/actions");
const ipythonAction: ActionMessage = {
id: 2,
source: "agent",
action: ActionType.RUN_IPYTHON,
args: {
code: "print('Hello from Jupyter!')",
},
message: "Running Python code interactively: print('Hello from Jupyter!')",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(ipythonAction);
// Check that appendJupyterInput was called with the code
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
expect(mockAppendInput).not.toHaveBeenCalled();
});
it("should not process hidden actions", async () => {
const { handleActionMessage } = await import("#/services/actions");
const hiddenAction: ActionMessage = {
id: 3,
source: "agent",
action: ActionType.RUN,
args: {
command: "secret command",
hidden: "true",
},
message: "Running command: secret command",
timestamp: "2023-01-01T00:00:00Z",
};
// Handle the action
handleActionMessage(hiddenAction);
// Check that nothing was dispatched
expect(mockDispatch).not.toHaveBeenCalled();
});
});

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);
});
});
});

View File

@@ -82,17 +82,5 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",
separator: "/",
});
expect(extractModelAndProvider("claude-2.1")).toEqual({
provider: "anthropic",
model: "claude-2.1",
separator: "/",
});
});
});

View File

@@ -52,14 +52,16 @@ test("organizeModelsAndProviders", () => {
separator: "/",
models: [
"claude-3-5-sonnet-20241022",
],
},
other: {
separator: "",
models: [
"together-ai-21.1b-41b",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",
],
},
other: {
separator: "",
models: ["together-ai-21.1b-41b"],
},
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,13 @@
{
"name": "openhands-frontend",
"version": "0.47.0",
"version": "0.49.0",
"private": true,
"type": "module",
"engines": {
"node": ">=20.0.0"
"node": ">=22.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.0-beta.10",
"@heroui/react": "^2.8.0-beta.15",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.6.3",
@@ -18,27 +18,27 @@
"@stripe/stripe-js": "^7.4.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.81.4",
"@tanstack/react-query": "^5.83.0",
"@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.19.2",
"i18next": "^25.2.1",
"framer-motion": "^12.23.5",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.28",
"jose": "^6.0.11",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.255.1",
"posthog-js": "^1.257.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.5.3",
"react-i18next": "^15.6.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
@@ -49,7 +49,7 @@
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.0",
"vite": "^7.0.4",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -70,7 +70,6 @@
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
"prepare": "cd .. && husky frontend/.husky",
"typecheck": "react-router typegen && tsc",
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
},
"lint-staged": {
@@ -80,11 +79,11 @@
]
},
"devDependencies": {
"@babel/parser": "^7.27.7",
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.0",
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.1",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.1",
"@playwright/test": "^1.54.1",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
@@ -92,7 +91,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.5",
"@types/node": "^24.0.13",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -107,6 +106,7 @@
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
@@ -118,7 +118,7 @@
"lint-staged": "^16.1.2",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.2.1",
"stripe": "^18.3.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",

View File

@@ -1,739 +0,0 @@
#!/usr/bin/env node
/**
* Pre-commit hook script to check for unlocalized strings in the frontend code
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
*/
const path = require('path');
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
// Files/directories to ignore
const IGNORE_PATHS = [
// Build and dependency files
"node_modules",
"dist",
".git",
"test",
"__tests__",
".d.ts",
"i18n",
"package.json",
"package-lock.json",
"tsconfig.json",
// Internal code that doesn't need localization
"mocks", // Mock data
"assets", // SVG paths and CSS classes
"types", // Type definitions and constants
"state", // Redux state management
"api", // API endpoints
"services", // Internal services
"hooks", // React hooks
"context", // React context
"store", // Redux store
"routes.ts", // Route definitions
"root.tsx", // Root component
"entry.client.tsx", // Client entry point
"utils/scan-unlocalized-strings.ts", // Original scanner
"utils/scan-unlocalized-strings-ast.ts", // This file itself
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
];
// Extensions to scan
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
// Attributes that typically don't contain user-facing text
const NON_TEXT_ATTRIBUTES = [
"allow",
"className",
"i18nKey",
"testId",
"id",
"name",
"type",
"href",
"src",
"rel",
"target",
"style",
"onClick",
"onChange",
"onSubmit",
"data-testid",
"aria-labelledby",
"aria-describedby",
"aria-hidden",
"role",
"sandbox",
];
function shouldIgnorePath(filePath) {
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
}
// Check if a string looks like a translation key
// Translation keys typically use dots, underscores, or are all caps
// Also check for the pattern with $ which is used in our translation keys
function isLikelyTranslationKey(str) {
return (
/^[A-Z0-9_$.]+$/.test(str) ||
str.includes(".") ||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
);
}
// Check if a string is a raw translation key that should be wrapped in t()
function isRawTranslationKey(str) {
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
// Exclude specific keys that are already properly used with i18next.t() in the code
const excludedKeys = [
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
"ERROR$GENERIC",
"GITHUB$AUTH_SCOPE",
];
if (excludedKeys.includes(str)) {
return false;
}
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
}
// Specific technical strings that should be excluded from localization
const EXCLUDED_TECHNICAL_STRINGS = [
"openid email profile", // OAuth scope string - not user-facing
"OPEN_ISSUE", // Task type identifier, not a UI string
"Merge Request", // Git provider specific terminology
"GitLab API", // Git provider specific terminology
"Pull Request", // Git provider specific terminology
"GitHub API", // Git provider specific terminology
"add-secret-form", // Test ID for secret form
"edit-secret-form", // Test ID for secret form
"search-api-key-input", // Input name for search API key
"noopener,noreferrer", // Options for window.open
"STATUS$READY",
"STATUS$STOPPED",
"STATUS$ERROR",
];
function isExcludedTechnicalString(str) {
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
}
function isLikelyCode(str) {
// A string with no spaces and at least one underscore or colon is likely a code.
// (e.g.: "browser_interactive" or "error:")
if (str.includes(" ")) {
return false
}
if (str.includes(":") || str.includes("_")){
return true
}
return false
}
function isCommonDevelopmentString(str) {
// Technical patterns that are definitely not UI strings
const technicalPatterns = [
// URLs and paths
/^https?:\/\//, // URLs
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
/^@[a-zA-Z0-9/-]+$/, // Import paths
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
/^application\/[a-zA-Z0-9-]+$/, // MIME types
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
// Numbers, IDs, and technical values
/^\d+(\.\d+)?$/, // Numbers
/^#[0-9a-fA-F]{3,8}$/, // Color codes
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
/^mm:ss$/, // Time format
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
/^[A-Za-z0-9+/=]+$/, // Base64
// HTML and CSS selectors
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
// CSS and styling patterns
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
];
// File extensions and media types
const fileExtensionPattern =
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
if (fileExtensionPattern.test(str)) {
return true;
}
// AI model and provider patterns
const aiRelatedPattern =
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
if (aiRelatedPattern.test(str)) {
return true;
}
// CSS units and values
const cssUnitsPattern =
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
const cssValuesPattern =
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
return true;
}
// Check for CSS class strings with brackets (common in the codebase)
if (
str.includes("[") &&
str.includes("]") &&
(str.includes("px") ||
str.includes("rem") ||
str.includes("em") ||
str.includes("w-") ||
str.includes("h-") ||
str.includes("p-") ||
str.includes("m-"))
) {
return true;
}
// Check for CSS class strings with specific patterns
if (
str.includes("border-") ||
str.includes("rounded-") ||
str.includes("cursor-") ||
str.includes("opacity-") ||
str.includes("disabled:") ||
str.includes("hover:") ||
str.includes("focus-within:") ||
str.includes("first-of-type:") ||
str.includes("last-of-type:") ||
str.includes("group-data-")
) {
return true;
}
// Check if it looks like a Tailwind class string
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
// Common Tailwind prefixes and patterns
const tailwindPrefixes = [
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
];
// Check if any word in the string starts with a Tailwind prefix
const words = str.split(/\s+/);
for (const word of words) {
for (const prefix of tailwindPrefixes) {
if (word.startsWith(prefix)) {
return true;
}
}
}
// Check for Tailwind modifiers
const tailwindModifiers = [
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
];
for (const word of words) {
for (const modifier of tailwindModifiers) {
if (word.includes(modifier)) {
return true;
}
}
}
// Check for CSS property combinations
const cssProperties = [
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
];
// If the string contains multiple CSS properties, it's likely a CSS class string
let cssPropertyCount = 0;
for (const word of words) {
if (
cssProperties.some(
(prop) => word === prop || word.startsWith(`${prop}-`),
)
) {
cssPropertyCount += 1;
}
}
if (cssPropertyCount >= 2) {
return true;
}
}
// Check for specific CSS class patterns that appear in the test failures
if (
str.match(
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
)
) {
return true;
}
// HTML tags and attributes
if (
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
) {
return true;
}
// Check for specific patterns in suggestions and examples
if (
str.includes("* ") &&
(str.includes("create a") ||
str.includes("build a") ||
str.includes("make a"))
) {
// This is likely a suggestion or example, not a UI string
return false;
}
// Check for specific technical identifiers from the test failures
if (
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
str,
)
) {
return true;
}
// Check for URL paths and query parameters
if (
str.startsWith("?") ||
str.startsWith("/") ||
str.includes("auth.") ||
str.includes("$1auth.")
) {
return true;
}
// Check for specific strings that should be excluded
if (
str === "Cache Hit:" ||
str === "Cache Write:" ||
str === "ADD_DOCS" ||
str === "ADD_DOCKERFILE" ||
str === "Verified" ||
str === "Others" ||
str === "Feedback" ||
str === "JSON File" ||
str === "mt-0.5 md:mt-0"
) {
return true;
}
// Check for long suggestion texts
if (
str.length > 100 &&
(str.includes("Please write a bash script") ||
str.includes("Please investigate the repo") ||
str.includes("Please push the changes") ||
str.includes("Examine the dependencies") ||
str.includes("Investigate the documentation") ||
str.includes("Investigate the current repo") ||
str.includes("I want to create a Hello World app") ||
str.includes("I want to create a VueJS app") ||
str.includes("This should be a client-only app"))
) {
return true;
}
// Check for specific error messages and UI text
if (
str === "All data associated with this project will be lost." ||
str === "You will lose any unsaved information." ||
str ===
"This conversation does not exist, or you do not have permission to access it." ||
str === "Failed to fetch settings. Please try reloading." ||
str ===
"If you tell OpenHands to start a web server, the app will appear here." ||
str ===
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
str ===
"Something went wrong while fetching settings. Please reload the page." ||
str ===
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
str === "Please push the latest changes to the existing pull request."
) {
return true;
}
// Check against all technical patterns
return technicalPatterns.some((pattern) => pattern.test(str));
}
function isLikelyUserFacingText(str) {
// Basic validation - skip very short strings or strings without letters
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
return false;
}
// Check if it's a specifically excluded technical string
if (isExcludedTechnicalString(str)) {
return false;
}
// Check if it looks like a code rather than a key
if (isLikelyCode(str)) {
return false
}
// Check if it's a raw translation key that should be wrapped in t()
if (isRawTranslationKey(str)) {
return true;
}
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
// These should be wrapped in t() or use I18nKey enum
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
return true;
}
// First, check if it's a common development string (not user-facing)
if (isCommonDevelopmentString(str)) {
return false;
}
// Multi-word phrases are likely UI text
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
// Sentences and questions are likely UI text
const hasPunctuation = /[?!.,:]/.test(str);
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
const hasQuestionForm =
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
str,
);
// Product names and camelCase identifiers are likely UI text
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
// Instruction text patterns are likely UI text
const looksLikeInstruction =
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
str,
);
// Error and status messages are likely UI text
const looksLikeErrorOrStatus =
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
str,
);
// Single word check - assume it's UI text unless proven otherwise
const isSingleWord =
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
// For single words, we need to be more careful
if (isSingleWord) {
// Skip common programming terms and variable names
const isCommonProgrammingTerm =
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
str,
);
if (isCommonProgrammingTerm) {
return false;
}
// Skip common variable name patterns
const looksLikeVariableName =
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
if (looksLikeVariableName) {
return false;
}
// Skip common CSS values
const isCommonCssValue =
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
str,
);
if (isCommonCssValue) {
return false;
}
// Skip common file extensions
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
if (isFileExtension) {
return false;
}
// Skip common abbreviations
const isCommonAbbreviation =
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
str,
);
if (isCommonAbbreviation) {
return false;
}
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
// it might be UI text, but we'll be conservative and return false
return false;
}
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
return (
hasMultipleWords ||
hasPunctuation ||
isCapitalizedPhrase ||
isTitleCase ||
hasSentenceStructure ||
hasQuestionForm ||
hasInternalCapitals ||
looksLikeInstruction ||
looksLikeErrorOrStatus
);
}
function isInTranslationContext(path) {
// Check if the JSX text is inside a <Trans> component
let current = path;
while (current.parentPath) {
if (
current.isJSXElement() &&
current.node.openingElement &&
current.node.openingElement.name &&
current.node.openingElement.name.name === "Trans"
) {
return true;
}
current = current.parentPath;
}
return false;
}
function scanFileForUnlocalizedStrings(filePath) {
// Skip suggestion content files as they contain special strings that are already properly localized
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
return [];
}
try {
const content = fs.readFileSync(filePath, "utf-8");
const unlocalizedStrings = [];
// Skip files that are too large
if (content.length > 1000000) {
console.warn(`Skipping large file: ${filePath}`);
return [];
}
try {
// Parse the file
const ast = parser.parse(content, {
sourceType: "module",
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
});
// Traverse the AST
traverse(ast, {
// Find JSX text content
JSXText(jsxTextPath) {
const text = jsxTextPath.node.value.trim();
if (
text &&
isLikelyUserFacingText(text) &&
!isInTranslationContext(jsxTextPath)
) {
unlocalizedStrings.push(text);
}
},
// Find string literals in JSX attributes
JSXAttribute(jsxAttrPath) {
const attrName = jsxAttrPath.node.name.name.toString();
// Skip technical attributes that don't contain user-facing text
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
return;
}
// Skip styling attributes
if (
attrName === "className" ||
attrName === "class" ||
attrName === "style"
) {
return;
}
// Skip data attributes and event handlers
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
return;
}
// Check the attribute value
const value = jsxAttrPath.node.value;
if (value && value.type === "StringLiteral") {
const text = value.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
// Find string literals in code
StringLiteral(stringPath) {
// Skip if parent is JSX attribute (already handled above)
if (stringPath.parent.type === "JSXAttribute") {
return;
}
// Skip if parent is import/export declaration
if (
stringPath.parent.type === "ImportDeclaration" ||
stringPath.parent.type === "ExportDeclaration"
) {
return;
}
// Skip if parent is object property key
if (
stringPath.parent.type === "ObjectProperty" &&
stringPath.parent.key === stringPath.node
) {
return;
}
// Skip if inside a t() call or Trans component
let isInsideTranslation = false;
let current = stringPath;
while (current.parentPath && !isInsideTranslation) {
// Check for t() function call
if (
current.parent.type === "CallExpression" &&
current.parent.callee &&
((current.parent.callee.type === "Identifier" &&
current.parent.callee.name === "t") ||
(current.parent.callee.type === "MemberExpression" &&
current.parent.callee.property &&
current.parent.callee.property.name === "t"))
) {
isInsideTranslation = true;
break;
}
// Check for <Trans> component
if (
current.parent.type === "JSXElement" &&
current.parent.openingElement &&
current.parent.openingElement.name &&
current.parent.openingElement.name.name === "Trans"
) {
isInsideTranslation = true;
break;
}
current = current.parentPath;
}
if (!isInsideTranslation) {
const text = stringPath.node.value.trim();
if (text && isLikelyUserFacingText(text)) {
unlocalizedStrings.push(text);
}
}
},
});
return unlocalizedStrings;
} catch (error) {
console.error(`Error parsing file ${filePath}:`, error);
return [];
}
} catch (error) {
console.error(`Error reading file ${filePath}:`, error);
return [];
}
}
function scanDirectoryForUnlocalizedStrings(dirPath) {
const results = new Map();
function scanDir(currentPath) {
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (!shouldIgnorePath(fullPath)) {
if (entry.isDirectory()) {
scanDir(fullPath);
} else if (
entry.isFile() &&
SCAN_EXTENSIONS.includes(path.extname(fullPath))
) {
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
if (unlocalized.length > 0) {
results.set(fullPath, unlocalized);
}
}
}
}
}
scanDir(dirPath);
return results;
}
// Run the check
try {
const srcPath = path.resolve(__dirname, '../src');
console.log('Checking for unlocalized strings in frontend code...');
// Get unlocalized strings using the AST scanner
const results = scanDirectoryForUnlocalizedStrings(srcPath);
// If we found any unlocalized strings, format them for output and exit with error
if (results.size > 0) {
const formattedResults = Array.from(results.entries())
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
.join('\n');
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
process.exit(1);
}
console.log('✅ No unlocalized strings found in frontend code.');
process.exit(0);
} catch (error) {
console.error('Error running unlocalized strings check:', error);
process.exit(1);
}

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>(
@@ -479,6 +477,18 @@ class OpenHands {
return data.prompt;
}
static async updateConversation(
conversationId: string,
updates: { title: string },
): Promise<boolean> {
const { data } = await openHands.patch<boolean>(
`/api/conversations/${conversationId}`,
updates,
);
return data;
}
}
export default OpenHands;

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;
@@ -70,6 +71,12 @@ export interface AuthenticateResponse {
error?: string;
}
export interface RepositorySelection {
selected_repository: string | null;
selected_branch: string | null;
git_provider: Provider | null;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export interface Conversation {
@@ -77,7 +84,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

@@ -29,7 +29,7 @@ export function ChatInput({
disabled,
showButton = true,
value,
maxRows = 16,
maxRows = 8,
onSubmit,
onStop,
onChange,

View File

@@ -0,0 +1,164 @@
import { render, screen } from "@testing-library/react";
import { useParams } from "react-router";
import { vi, describe, test, expect, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ChatInterface } from "./chat-interface";
import { useWsClient } from "#/context/ws-client-provider";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
import { useConfig } from "#/hooks/query/use-config";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { OpenHandsAction } from "#/types/core/actions";
// Mock the hooks
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/use-optimistic-user-message");
vi.mock("#/hooks/use-ws-error-message");
vi.mock("react-router");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
vi.mock("react-redux", () => ({
useSelector: vi.fn(() => ({
curAgentState: "AWAITING_USER_INPUT",
selectedRepository: null,
replayJson: null,
})),
}));
describe("ChatInterface", () => {
// Create a new QueryClient for each test
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Default mock implementations
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => null),
});
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
getErrorMessage: vi.fn(() => null),
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
conversationId: "test-id",
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isLoading: false,
});
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
mutateAsync: vi
.fn()
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
isLoading: false,
});
});
// Helper function to render with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) =>
render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
test("should show chat suggestions when there are no events", () => {
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is rendered
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should show chat suggestions when there are only environment events", () => {
const environmentEvent: OpenHandsAction = {
id: 1,
source: "environment",
action: "system",
args: {
content: "source .openhands/setup.sh",
tools: null,
openhands_version: null,
agent_class: null,
},
message: "Running setup script",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [environmentEvent],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is still rendered with environment events
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
});
test("should hide chat suggestions when there is a user message", () => {
const userEvent: OpenHandsAction = {
id: 1,
source: "user",
action: "message",
args: {
content: "Hello",
image_urls: [],
file_urls: [],
},
message: "Hello",
timestamp: "2025-07-01T00:00:00Z",
};
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
send: vi.fn(),
isLoadingMessages: false,
parsedEvents: [userEvent],
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is not rendered with user events
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
test("should hide chat suggestions when there is an optimistic user message", () => {
(
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
).mockReturnValue({
setOptimisticUserMessage: vi.fn(),
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
});
renderWithQueryClient(<ChatInterface />);
// Check if ChatSuggestions is not rendered with optimistic user message
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
});
});

View File

@@ -10,6 +10,7 @@ import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { isOpenHandsAction } from "#/types/core/guards";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { FeedbackModal } from "../feedback/feedback-modal";
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
@@ -31,6 +32,7 @@ import { ErrorMessageBanner } from "./error-message-banner";
import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
function getEntryPoint(
hasRepository: boolean | null,
@@ -77,11 +79,26 @@ export function ChatInterface() {
const events = parsedEvents.filter(shouldRenderEvent);
// Check if there are any substantive agent actions (not just system messages)
const hasSubstantiveAgentActions = React.useMemo(
() =>
parsedEvents.some(
(event) =>
isOpenHandsAction(event) &&
event.source === "agent" &&
event.action !== "system",
),
[parsedEvents],
);
const handleSendMessage = async (
content: string,
images: File[],
files: File[],
originalImages: File[],
originalFiles: File[],
) => {
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
if (events.length === 0) {
posthog.capture("initial_query_submitted", {
entry_point: getEntryPoint(
@@ -97,6 +114,16 @@ export function ChatInterface() {
current_message_length: content.length,
});
}
// Validate file sizes before any processing
const allFiles = [...images, ...files];
const validation = validateFiles(allFiles);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Stop processing if validation fails
}
const promises = images.map((image) => convertImageToBase64(image));
const imageUrls = await Promise.all(promises);
@@ -167,9 +194,12 @@ export function ChatInterface() {
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between">
{events.length === 0 && !optimisticUserMessage && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
{!hasSubstantiveAgentActions &&
!optimisticUserMessage &&
!events.some(
(event) => isOpenHandsAction(event) && event.source === "user",
) && <ChatSuggestions onSuggestionsClick={setMessageToSend} />}
{/* Note: We only hide chat suggestions when there's a user message */}
<div
ref={scrollRef}
@@ -192,7 +222,7 @@ export function ChatInterface() {
)}
{isWaitingForUserInput &&
events.length > 0 &&
hasSubstantiveAgentActions &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}

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,18 +52,39 @@ 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={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">
<Markdown
components={{

View File

@@ -12,7 +12,10 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
<div
data-testid="chat-suggestions"
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
>
<div className="flex flex-col items-center p-4 bg-tertiary rounded-xl w-full">
<BuildIt width={45} height={54} />
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">

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()}
</>
);
@@ -113,7 +165,7 @@ export function EventMessage({
return (
<>
<ChatMessage type={event.source} message={message}>
<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,6 +174,13 @@ export function EventMessage({
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</ChatMessage>
{microagentStatus && actions && (
<MicroagentStatusIndicator
status={microagentStatus}
conversationId={microagentConversationId}
prUrl={microagentPRUrl}
/>
)}
{isAssistantMessage(event) &&
event.action === "message" &&
renderLikertScale()}
@@ -130,7 +189,11 @@ export function EventMessage({
}
if (isRejectObservation(event)) {
return <ChatMessage type="agent" message={event.content} />;
return (
<div>
<ChatMessage type="agent" message={event.content} />
</div>
);
}
if (isMcpObservation(event)) {

View File

@@ -5,6 +5,8 @@ import { ImageCarousel } from "../images/image-carousel";
import { UploadImageInput } from "../images/upload-image-input";
import { FileList } from "../files/file-list";
import { isFileImage } from "#/utils/is-file-image";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { validateFiles } from "#/utils/file-validation";
interface InteractiveChatBoxProps {
isDisabled?: boolean;
@@ -27,14 +29,20 @@ export function InteractiveChatBox({
const [files, setFiles] = React.useState<File[]>([]);
const handleUpload = (selectedFiles: File[]) => {
setFiles((prevFiles) => [
...prevFiles,
...selectedFiles.filter((f) => !isFileImage(f)),
]);
setImages((prevImages) => [
...prevImages,
...selectedFiles.filter((f) => isFileImage(f)),
]);
// Validate files before adding them
const validation = validateFiles(selectedFiles, [...images, ...files]);
if (!validation.isValid) {
displayErrorToast(`Error: ${validation.errorMessage}`);
return; // Don't add any files if validation fails
}
// Filter valid files by type
const validFiles = selectedFiles.filter((f) => !isFileImage(f));
const validImages = selectedFiles.filter((f) => isFileImage(f));
setFiles((prevFiles) => [...prevFiles, ...validFiles]);
setImages((prevImages) => [...prevImages, ...validImages]);
};
const removeElementByIndex = (array: Array<File>, index: number) => {

View File

@@ -1,10 +1,28 @@
import React from "react";
import { createPortal } from "react-dom";
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";
import MemoryIcon from "#/icons/memory_icon.svg?react";
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,26 @@ 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={
conversation?.selected_repository
? [
{
icon: (
<MemoryIcon className="w-[14px] h-[14px] text-white" />
),
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
},
]
: undefined
}
isInLast10Actions={messages.length - 1 - index < 10}
/>
))}
@@ -46,6 +230,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

@@ -4,6 +4,7 @@ import { AgentStatusBar } from "./agent-status-bar";
import { SecurityLock } from "./security-lock";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
import { Provider } from "#/types/settings";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@@ -29,7 +30,11 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
showOptions
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}
selectedRepository={{
selected_repository: conversation?.selected_repository ?? null,
selected_branch: conversation?.selected_branch ?? null,
git_provider: (conversation?.git_provider as Provider) ?? null,
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
/>

View File

@@ -17,8 +17,12 @@ export function BudgetUsageText({
return (
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
{t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, {
currentCost: `$${currentCost.toFixed(4)}`,
maxBudget: `$${maxBudget.toFixed(4)}`,
usagePercentage: usagePercentage.toFixed(2),
used: t(I18nKey.CONVERSATION$USED),
})}
</span>
</div>
);

View File

@@ -0,0 +1,57 @@
import { useTranslation } from "react-i18next";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface ConfirmStopModalProps {
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmStopModal({
onConfirm,
onCancel,
}: ConfirmStopModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<ModalBody className="items-start border border-tertiary">
<div className="flex flex-col gap-2">
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
<BaseModalDescription
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
/>
</div>
<div
className="flex flex-col gap-2 w-full"
onClick={(event) => event.stopPropagation()}
>
<BrandButton
type="button"
variant="primary"
onClick={onConfirm}
className="w-full"
data-testid="confirm-button"
>
{t(I18nKey.ACTION$CONFIRM)}
</BrandButton>
<BrandButton
type="button"
variant="secondary"
onClick={onCancel}
className="w-full"
data-testid="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}

View File

@@ -8,6 +8,7 @@ import { I18nKey } from "#/i18n/declaration";
interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
@@ -19,6 +20,7 @@ interface ConversationCardContextMenuProps {
export function ConversationCardContextMenu({
onClose,
onDelete,
onStop,
onEdit,
onDisplayCost,
onShowAgentTools,
@@ -41,12 +43,17 @@ export function ConversationCardContextMenu({
>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
Delete
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
Edit Title
{t(I18nKey.BUTTON$EDIT_TITLE)}
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
@@ -54,7 +61,7 @@ export function ConversationCardContextMenu({
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
Download via VS Code
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
</ContextMenuListItem>
)}
{onDisplayCost && (
@@ -62,7 +69,7 @@ export function ConversationCardContextMenu({
testId="display-cost-button"
onClick={onDisplayCost}
>
Display Cost
{t(I18nKey.BUTTON$DISPLAY_COST)}
</ContextMenuListItem>
)}
{onShowAgentTools && (
@@ -70,7 +77,7 @@ export function ConversationCardContextMenu({
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
Show Agent Tools & Metadata
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
</ContextMenuListItem>
)}
{onShowMicroagents && (

View File

@@ -19,15 +19,17 @@ import OpenHands from "#/api/open-hands";
import { useWsClient } from "#/context/ws-client-provider";
import { isSystemMessage } from "#/types/core/guards";
import { ConversationStatus } from "#/types/conversation-status";
import { RepositorySelection } from "#/api/open-hands.types";
interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onStop?: () => void;
onChangeTitle?: (title: string) => void;
showOptions?: boolean;
isActive?: boolean;
title: string;
selectedRepository: string | null;
selectedRepository: RepositorySelection | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
@@ -40,6 +42,7 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
export function ConversationCard({
onClick,
onDelete,
onStop,
onChangeTitle,
showOptions,
isActive,
@@ -101,6 +104,13 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
setContextMenuVisible(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
@@ -171,7 +181,7 @@ export function ConversationCard({
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
"h-auto w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
variant === "compact" &&
"md:w-fit h-auto rounded-xl border border-[#525252]",
)}
@@ -224,6 +234,11 @@ export function ConversationCard({
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
? onStop && handleStop
: undefined
}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
@@ -250,28 +265,33 @@ export function ConversationCard({
<div
className={cn(
variant === "compact" && "flex items-center justify-between mt-1",
variant === "compact" && "flex flex-col justify-between mt-1",
)}
>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
{selectedRepository?.selected_repository && (
<ConversationRepoLink
selectedRepository={selectedRepository}
variant={variant}
/>
)}
{(createdAt || lastUpdatedAt) && (
<p className="text-xs text-neutral-400">
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
<time>
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</time>
{showUpdateTime && (
<>
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
<time>
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</time>
</>
)}
</p>
)}
<p className="text-xs text-neutral-400">
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
<time>
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</time>
{showUpdateTime && (
<>
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
<time>
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
{t(I18nKey.CONVERSATION$AGO)}
</time>
</>
)}
</p>
</div>
</div>
@@ -310,11 +330,15 @@ export function ConversationCard({
</div>
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
<span className="text-neutral-400">Cache Hit:</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_HIT)}
</span>
<span className="text-right">
{metrics.usage.cache_read_tokens.toLocaleString()}
</span>
<span className="text-neutral-400">Cache Write:</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
</span>
<span className="text-right">
{metrics.usage.cache_write_tokens.toLocaleString()}
</span>
@@ -389,10 +413,7 @@ export function ConversationCard({
/>
{microagentsModalVisible && (
<MicroagentsModal
onClose={() => setMicroagentsModalVisible(false)}
conversationId={conversationId}
/>
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
)}
</>
);

View File

@@ -5,10 +5,15 @@ import { I18nKey } from "#/i18n/declaration";
import { ConversationCard } from "./conversation-card";
import { useUserConversations } from "#/hooks/query/use-user-conversations";
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
import { ConfirmDeleteModal } from "./confirm-delete-modal";
import { ConfirmStopModal } from "./confirm-stop-modal";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { ExitConversationModal } from "./exit-conversation-modal";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { Provider } from "#/types/settings";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
interface ConversationPanelProps {
onClose: () => void;
@@ -22,6 +27,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
@@ -33,12 +40,33 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: updateConversation } = useUpdateConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleStopConversation = (conversationId: string) => {
setConfirmStopModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleConversationTitleChange = async (
conversationId: string,
newTitle: string,
) => {
updateConversation(
{ conversationId, newTitle },
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.CONVERSATION$TITLE_UPDATED));
},
},
);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation(
@@ -54,6 +82,21 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
};
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
}
},
},
);
}
};
return (
<div
ref={ref}
@@ -87,8 +130,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
onChangeTitle={(title) =>
handleConversationTitleChange(project.conversation_id, title)
}
title={project.title}
selectedRepository={project.selected_repository}
selectedRepository={{
selected_repository: project.selected_repository,
selected_branch: project.selected_branch,
git_provider: project.git_provider as Provider,
}}
lastUpdatedAt={project.last_updated_at}
createdAt={project.created_at}
conversationStatus={project.status}
@@ -108,6 +159,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
/>
)}
{confirmStopModalVisible && (
<ConfirmStopModal
onConfirm={() => {
handleConfirmStop();
setConfirmStopModalVisible(false);
}}
onCancel={() => setConfirmStopModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {

View File

@@ -1,16 +1,44 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { RepositorySelection } from "#/api/open-hands.types";
interface ConversationRepoLinkProps {
selectedRepository: string;
selectedRepository: RepositorySelection;
variant: "compact" | "default";
}
export function ConversationRepoLink({
selectedRepository,
variant = "default",
}: ConversationRepoLinkProps) {
if (variant === "compact") {
return (
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository.selected_repository}
</span>
);
}
return (
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository}
</span>
<div className="flex items-center gap-1">
{selectedRepository.git_provider === "github" && <FaGithub size={14} />}
{selectedRepository.git_provider === "gitlab" && <FaGitlab />}
{selectedRepository.git_provider === "bitbucket" && <FaBitbucket />}
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository.selected_repository}
</span>
<code
data-testid="conversation-card-selected-branch"
className="text-xs text-neutral-400 border border-neutral-700 rounded px-1 py-0.5 w-fit bg-neutral-800"
>
{selectedRepository.selected_branch}
</code>
</div>
);
}

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

@@ -1,34 +1,33 @@
import React, { useState } from "react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useSelector } from "react-redux";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import { I18nKey } from "#/i18n/declaration";
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
import { BrandButton } from "../settings/brand-button";
interface MicroagentsModalProps {
onClose: () => void;
conversationId: string | undefined;
}
export function MicroagentsModal({
onClose,
conversationId,
}: MicroagentsModalProps) {
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
const { t } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
{},
);
const {
data: microagents,
isLoading,
isError,
} = useConversationMicroagents({
conversationId,
enabled: true,
});
refetch,
isRefetching,
} = useConversationMicroagents();
const toggleAgent = (agentName: string) => {
setExpandedAgents((prev) => ({
@@ -37,6 +36,10 @@ export function MicroagentsModal({
}));
};
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
curAgentState,
);
return (
<ModalBackdrop onClose={onClose}>
<ModalBody
@@ -45,10 +48,40 @@ export function MicroagentsModal({
testID="microagents-modal"
>
<div className="flex flex-col gap-6 w-full">
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
<div className="flex items-center justify-between w-full">
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
{isAgentReady && (
<BrandButton
testId="refresh-microagents"
type="button"
variant="primary"
className="flex items-center gap-2"
onClick={refetch}
isDisabled={isLoading || isRefetching}
>
<RefreshCw
size={16}
className={`${isRefetching ? "animate-spin" : ""}`}
/>
{t(I18nKey.BUTTON$REFRESH)}
</BrandButton>
)}
</div>
</div>
{isAgentReady && (
<span className="text-sm text-gray-400">
{t(I18nKey.MICROAGENTS_MODAL$WARNING)}
</span>
)}
<div className="w-full h-[60vh] overflow-auto rounded-md">
{!isAgentReady && (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
</div>
)}
{isLoading && (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
@@ -56,6 +89,7 @@ export function MicroagentsModal({
)}
{!isLoading &&
isAgentReady &&
(isError || !microagents || microagents.length === 0) && (
<div className="flex items-center justify-center h-full p-4">
<p className="text-gray-400">
@@ -66,75 +100,81 @@ export function MicroagentsModal({
</div>
)}
{!isLoading && microagents && microagents.length > 0 && (
<div className="p-2 space-y-3">
{microagents.map((agent) => {
const isExpanded = expandedAgents[agent.name] || false;
{!isLoading &&
isAgentReady &&
microagents &&
microagents.length > 0 && (
<div className="p-2 space-y-3">
{microagents.map((agent) => {
const isExpanded = expandedAgents[agent.name] || false;
return (
<div key={agent.name} className="rounded-md overflow-hidden">
<button
type="button"
onClick={() => toggleAgent(agent.name)}
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
return (
<div
key={agent.name}
className="rounded-md overflow-hidden"
>
<div className="flex items-center">
<h3 className="font-bold text-gray-100">
{agent.name}
</h3>
</div>
<div className="flex items-center">
<span className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
{agent.type === "repo" ? "Repository" : "Knowledge"}
</span>
<span className="text-gray-300">
{isExpanded ? (
<ChevronDown size={18} />
) : (
<ChevronRight size={18} />
)}
</span>
</div>
</button>
<button
type="button"
onClick={() => toggleAgent(agent.name)}
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
>
<div className="flex items-center">
<h3 className="font-bold text-gray-100">
{agent.name}
</h3>
</div>
<div className="flex items-center">
<span className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
{agent.type === "repo" ? "Repository" : "Knowledge"}
</span>
<span className="text-gray-300">
{isExpanded ? (
<ChevronDown size={18} />
) : (
<ChevronRight size={18} />
)}
</span>
</div>
</button>
{isExpanded && (
<div className="px-2 pb-3 pt-1">
{agent.triggers && agent.triggers.length > 0 && (
<div className="mt-2 mb-3">
{isExpanded && (
<div className="px-2 pb-3 pt-1">
{agent.triggers && agent.triggers.length > 0 && (
<div className="mt-2 mb-3">
<h4 className="text-sm font-semibold text-gray-300 mb-2">
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
</h4>
<div className="flex flex-wrap gap-1">
{agent.triggers.map((trigger) => (
<span
key={trigger}
className="px-2 py-1 text-xs rounded-full bg-blue-900"
>
{trigger}
</span>
))}
</div>
</div>
)}
<div className="mt-2">
<h4 className="text-sm font-semibold text-gray-300 mb-2">
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
</h4>
<div className="flex flex-wrap gap-1">
{agent.triggers.map((trigger) => (
<span
key={trigger}
className="px-2 py-1 text-xs rounded-full bg-blue-900"
>
{trigger}
</span>
))}
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
{agent.content ||
t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
</pre>
</div>
</div>
)}
<div className="mt-2">
<h4 className="text-sm font-semibold text-gray-300 mb-2">
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
</h4>
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
{agent.content ||
t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
</pre>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
)}
)}
</div>
);
})}
</div>
)}
</div>
</ModalBody>
</ModalBackdrop>

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

@@ -0,0 +1,98 @@
import { DiGit } from "react-icons/di";
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { VscCode } from "react-icons/vsc";
import { Container } from "#/components/layout/container";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TabContent } from "#/components/layout/tab-content";
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
import { useConversationId } from "#/hooks/use-conversation-id";
import GlobeIcon from "#/icons/globe.svg?react";
import JupyterIcon from "#/icons/jupyter.svg?react";
import OpenHands from "#/api/open-hands";
import TerminalIcon from "#/icons/terminal.svg?react";
export function ConversationTabs() {
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { conversationId } = useConversationId();
const { t } = useTranslation();
const basePath = `/conversations/${conversationId}`;
return (
<Container
className="h-full w-full"
labels={[
{
label: "Changes",
to: "",
icon: <DiGit className="w-6 h-6" />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.VSCODE$TITLE)}
</div>
),
to: "vscode",
icon: <VscCode className="w-5 h-5" />,
rightContent: !RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
<FaExternalLinkAlt
className="w-3 h-3 text-neutral-400 cursor-pointer"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
if (conversationId) {
try {
const data = await OpenHands.getVSCodeUrl(conversationId);
if (data.vscode_url) {
const transformedUrl = transformVSCodeUrl(
data.vscode_url,
);
if (transformedUrl) {
window.open(transformedUrl, "_blank");
}
}
} catch (err) {
// Silently handle the error
}
}
}}
/>
) : null,
},
{
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
to: "terminal",
icon: <TerminalIcon />,
},
{ label: "Jupyter", to: "jupyter", icon: <JupyterIcon /> },
{
label: <ServedAppLabel />,
to: "served",
icon: <FaServer />,
},
{
label: (
<div className="flex items-center gap-1">
{t(I18nKey.BROWSER$TITLE)}
</div>
),
to: "browser",
icon: <GlobeIcon />,
},
]}
>
{/* Use both Outlet and TabContent */}
<div className="h-full w-full">
<TabContent conversationPath={basePath} />
</div>
</Container>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext } from "react";
import { useTranslation } from "react-i18next";
import { FaStar } from "react-icons/fa";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { useSubmitConversationFeedback } from "#/hooks/mutation/use-submit-conversation-feedback";
@@ -207,7 +208,7 @@ export function LikertScale({
className={cn("text-xl transition-all", getButtonClass(rating))}
aria-label={`Rate ${rating} stars`}
>
<FaStar />
</button>
))}
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}

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

@@ -4,9 +4,10 @@ import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links";
import { useUserProviders } from "#/hooks/use-user-providers";
import { GitRepository } from "#/types/git";
interface RepoConnectorProps {
onRepoSelection: (repoTitle: string | null) => void;
onRepoSelection: (repo: GitRepository | null) => void;
}
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {

View File

@@ -1,20 +1,26 @@
import { useTranslation } from "react-i18next";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
export function RepoProviderLinks() {
const { t } = useTranslation();
const { data: config } = useConfig();
const { providers } = useUserProviders();
const githubHref = config
? `https://github.com/apps/${config.APP_SLUG}/installations/new`
: "";
const hasGithubProvider = providers.includes("github");
return (
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
<a href={githubHref} target="_blank" rel="noopener noreferrer">
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
{hasGithubProvider && (
<a href={githubHref} target="_blank" rel="noopener noreferrer">
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
)}
</div>
);
}

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";
@@ -19,12 +20,13 @@ import {
} from "./repository-selection";
interface RepositorySelectionFormProps {
onRepoSelection: (repoTitle: string | null) => void;
onRepoSelection: (repo: GitRepository | null) => void;
}
export function RepositorySelectionForm({
onRepoSelection,
}: RepositorySelectionFormProps) {
const navigate = useNavigate();
const [selectedRepository, setSelectedRepository] =
React.useState<GitRepository | null>(null);
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
@@ -94,8 +96,7 @@ export function RepositorySelectionForm({
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
if (selectedRepo) onRepoSelection(selectedRepo);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
@@ -209,10 +210,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,
});
};
@@ -64,7 +53,7 @@ export function TaskCard({ task }: TaskCardProps) {
}
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<li className="py-3 border-b border-[#717888] flex items-center pr-6 last:border-b-0">
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
<div className="w-full pl-8">

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