Compare commits

..

136 Commits

Author SHA1 Message Date
Rohit Malhotra d59a819e11 [Feat]: Custom secrets plumbing for BE (#7891)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Robert Brennan 2d6273a15f Add loading indicator to repository dropdown on home page (#8015)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Rohit Malhotra c1f7c400e6 (Chore): Rm legacy resolver code (#8001) 2025-04-23 14:21:56 -04:00
Graham Neubig 0d6d91b1fd Add extensive typing to controller directory (#7731)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 14:21:56 -04:00
Robert Brennan 4ef479dbdc Add API keys management UI to settings page (#7710)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
mamoodi 1632fe4a1f Release 0.34.0 (#8011) 2025-04-23 14:21:56 -04:00
Robert Brennan 8ecd4a4b80 add an option for a headless backend (#8032) 2025-04-23 14:21:56 -04:00
Chase ec1f72da93 replace erroneous rstrip() with removesuffix() (#8024) 2025-04-23 14:21:56 -04:00
Chase 3b8e9bcc2b remove sse subsection accessor of McpConfig in action_execution_client (#8021) 2025-04-23 14:21:56 -04:00
Engel Nyst c6826711d3 Use short tool descriptions for o4-mini (#8022) 2025-04-23 14:21:56 -04:00
Robert Brennan 466b099587 Fix: Don't show status indicator for command timeouts (#8012)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Robert Brennan 87de22bd82 Always run git init in SaaS mode regardless of workspace_base setting (#8014)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Rohit Malhotra 1f87801a85 Add RateLimitError and handle rate limiting in GitLab and GitHub services (#8003)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-04-23 14:21:56 -04:00
dependabot[bot] a35267b425 chore(deps): bump the version-all group with 6 updates (#8009)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 14:21:56 -04:00
Peter Hamilton 1d42e8de99 fix: Use SANDBOX_BASE_CONTAINER_IMAGE in resolver workflow (#7956)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
sp.wack 478da20c6f chore: Better home screen (#7784)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Rohit Malhotra 1d4041f1bf [Fix]: fetch latest token when existing token doesn't exist (#8000) 2025-04-23 14:21:56 -04:00
Robert Brennan e6a900ded6 Initialize git repo in workspace when no GitHub repo is selected (#7904)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Engel Nyst bb9dc8f6ca Fix and simplify local runtime init (#7997) 2025-04-23 14:21:56 -04:00
Rohit Malhotra 99f1bff8c4 Add HTTP method option to Git service fetch_data functions (#7996)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:56 -04:00
Xingyao Wang a7d93218a2 refactor: file viewer server so it is accessible via localhost without authentication (#7987)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Rohit Malhotra babf9b723c [Fix]: Dedup token verification logic in resolver (#7967)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Engel Nyst c41ea766d9 Quick fix local runtime (#7991) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 10e0f806d6 Fix dictionary changed size during iteration error in EventStream (#7984)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Michael Panchenko ea33598a84 Fix issue with "default" in tool schema for gemini-preview (#7964) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 759d2435cf [Feat]: Add branch naming convention (#7989) 2025-04-23 14:21:55 -04:00
Xingyao Wang e6f9b947b6 Add agent_class to SystemMessageAction and display in frontend (#7935)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Michael Panchenko 2bcadfbbf8 Minor refactoring: remove step and task-creation in AgentController (#7981) 2025-04-23 14:21:55 -04:00
dependabot[bot] 7625665cda chore(deps): bump the version-all group with 7 updates (#7980)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 14:21:55 -04:00
Rohit Malhotra 56ce1b73db [Fix]: Building runtime image with unspecified base container img (#7977) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 4f73a19356 (Chore): Unsupport resolve all issues in resolver (#7975) 2025-04-23 14:21:55 -04:00
Tetsuuuuuuu ec2119a32b feat: add argument --base-container-image to resolver (#7612)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 14:21:55 -04:00
Xingyao Wang 7aa3abb86f chore: improve logging for "not a git repository" (#7944) 2025-04-23 14:21:55 -04:00
Alexander Litzenberger 6dc5e4deae Update documentation on local-llms (#7805)
Co-authored-by: Alex Litzenberger <alex@agot.ai>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-04-23 14:21:55 -04:00
Michael Panchenko 0d51ab4f9d Fix linting (#7965) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 2e49b47ce2 [Fix]: Replace duplicate enums for providers in resolver (#7954) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 9ae2396734 [Fix]: mismatch between repo object definition between FE and BE (#7953) 2025-04-23 14:21:55 -04:00
Michael Panchenko 32065ab766 Possibility to disable default tools (#7951) 2025-04-23 14:21:55 -04:00
Tom Deckers db87274c8c Fix Github Enterprise GraphQL URL (#7939)
Co-authored-by: Tom Deckers <tdeckers@cisco.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-04-23 14:21:55 -04:00
Xingyao Wang 47e6612239 Fix #7916: Update Benchmark Score link in README (#7943)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Rohit Malhotra 333660e6a4 Add external auth id to /repos route (#7946) 2025-04-23 14:21:55 -04:00
Rohit Malhotra bd9f262fb3 (Chore): Rm deprecate /installations route (#7945) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 02cd6b1daf [Logging]: Add warning logs for gitlab api (#7941) 2025-04-23 14:21:55 -04:00
Kenny Dizi 1af8994b21 [Feat] Support o3 and o4 mini (#7898)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 14:21:55 -04:00
Rohit Malhotra e95de2abce Add public vs private info for repo list (#7937) 2025-04-23 14:21:55 -04:00
Xingyao Wang 9a57092a43 chore: track condeser metadata for llm completion (#7938) 2025-04-23 14:21:55 -04:00
Rohit Malhotra d81935867a [Feat]: Add graphql fetching for GitLab service cls (#7839) 2025-04-23 14:21:55 -04:00
Tei1988 9618366bbb fix(resolver): Allow using github.token in Actions and fix base_domain handling (#7934) 2025-04-23 14:21:55 -04:00
Rohit Malhotra 44fa2bff44 [Feat]: Improve resolver inline pr comment localization (#7932) 2025-04-23 14:21:55 -04:00
Xingyao Wang dda4a83e14 [agent] Read-only Agent (#6947)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:55 -04:00
Xingyao Wang 18f433d20e chore: make sure Makefile make lint also lint tests (#7933) 2025-04-23 14:21:55 -04:00
Engel Nyst deb1c64368 Refactor to clean up and move utility/legacy out of the agent (#7917) 2025-04-23 14:21:55 -04:00
Michael Panchenko d282396ed0 Bugfix: make extraction of poetry_venvs_path more robust (#7920) 2025-04-23 14:21:55 -04:00
Xingyao Wang 4b7d47fe5d fix(eval): typo in SWE_Bench evaluation (#7930) 2025-04-23 14:21:55 -04:00
dependabot[bot] c9e2076457 chore(deps): bump the version-all group across 1 directory with 20 updates (#7925)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
2025-04-23 14:21:55 -04:00
tofarr 23173f63d7 Refactor agent loop initialization for better extensibility (#7926) 2025-04-23 14:21:54 -04:00
dependabot[bot] 181a22f299 chore(deps): bump the version-all group with 7 updates (#7927)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 14:21:54 -04:00
Carlos Freund 44333cce4a fix(runtime) Avoid windows reserved port (#7722)
Co-authored-by: Carlos Freund <carlosfreund@gmail.com>
2025-04-23 14:21:54 -04:00
Engel Nyst 10f0fdf4fa [chore] Just linting on swe-bench files (#7918) 2025-04-23 14:21:54 -04:00
Xingyao Wang 622f28049a Refactor metrics handling to include condenser metrics (#7907)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
sp.wack 7bd2127adb hotfix(backend): Exclude open PRs when fetching suggested tasks (#7912) 2025-04-23 14:21:54 -04:00
Xingyao Wang b8310f05a9 refactor(action_execution_client): rename function and add property (#7913) 2025-04-23 14:21:54 -04:00
mamoodi 06d63784e4 Update documentation (#7905) 2025-04-23 14:21:54 -04:00
Niels Mündler 49fc779505 Add inference for SWT-Bench (#7201)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Calvin Smith <email@cjsmith.io>
2025-04-23 14:21:54 -04:00
Robert Brennan 52fa4a77c4 Improved logging for agent controller, including pending action time (#7897)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
Robert Brennan 643816b8ba Restore terminal interactivity while keeping UI changes (#7903)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
sp.wack dfe837870a hotfix(forntend): Wait for runtime to start before fetching git changes for diff view (#7910) 2025-04-23 14:21:54 -04:00
Rohit Malhotra fd562db2f7 [Refactor]: Collapse initial user message for cloud resolver (#7871)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
Robert Brennan e6e979c174 Change add funds input to number type that only accepts integers (#7628)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
Rohit Malhotra 752a3ec5f5 Update translation from GitHub Settings to Git Settings (#7908)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
Rohit Malhotra 6922f885c6 Fix failing unit test on main (#7909)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:21:54 -04:00
Carlos Freund 98fe41c6b2 fix(metrics) Merge metrics of agent LLM and condenser LLM (#7890)
Co-authored-by: Carlos Freund <carlosfreund@gmail.com>
2025-04-23 14:21:54 -04:00
chuckbutkus 48fb439686 Change client name and add IDP hint (#7787)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
Co-authored-by: Calvin Smith <email@cjsmith.io>
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
Co-authored-by: Panduka Muditha <pandukal@verdentra.com>
Co-authored-by: Bashwara Undupitiya <bashwarau@verdentra.com>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-04-23 14:21:54 -04:00
dependabot[bot] 10524eb79a chore(deps): bump the version-all group across 1 directory with 9 updates (#7902)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 14:18:00 -04:00
mamoodi 953cf837bb Change title of button to start new conversation (#7464) 2025-04-23 14:18:00 -04:00
juanmichelini 70ef550401 Jetbrains CI Benchmark (#7811)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 14:14:50 -04:00
Xingyao Wang 994d21df4f Refactor system message handling to use event stream (#7824)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Calvin Smith <email@cjsmith.io>
2025-04-23 14:14:50 -04:00
Ray Myers 7dc8fe682b chore - remove dummy agent test (#7848) 2025-04-23 14:14:50 -04:00
LoneRifle 5a82d646c3 fix(check-unlocalized-strings): make HTML tag test case-insens (#7892) 2025-04-23 14:14:50 -04:00
sp.wack 4a83806ec6 feat: Diff UI (#6934) 2025-04-23 14:14:50 -04:00
Rohit Malhotra e9ee0f9369 [Fix]: Rm unnecessary provider token serializer (#7889) 2025-04-23 14:10:53 -04:00
mamoodi c141d2a46c Release 0.33.0 (#7882)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: Robert Brennan <accounts@rbren.io>
Co-authored-by: Bashwara Undupitiya <65051545+bashwara@users.noreply.github.com>
2025-04-23 14:10:53 -04:00
Bashwara Undupitiya a23d1cfc6a fix: Update folder security dialog styling (#7886) 2025-04-23 14:10:53 -04:00
Robert Brennan 0923332930 Fix: Ensure consistent tab height when workspace tab is selected (#7885)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:10:53 -04:00
Rohit Malhotra 003d8ede71 [Refactor]: Modularize settings storage logic (#7868)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:10:53 -04:00
tofarr d4f6c7943a Fix for error on close (#7884) 2025-04-23 14:10:53 -04:00
dependabot[bot] 86840492ab chore(deps-dev): bump the eslint group across 1 directory with 2 updates (#7790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-23 14:10:53 -04:00
Xingyao Wang 3a3601c7b6 Update repo microagent docs with frontend action handling information (#7856)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:10:53 -04:00
Xingyao Wang 406008ecb0 frontend: fix terminal prompt and command styling (#7872)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:10:53 -04:00
Calvin Smith 72b240906b feat: Combining condensers (#7867)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-04-23 14:10:53 -04:00
Xingyao Wang 630b8af588 microagent: Update github.md to avoid agent marking PR as ready for review (#7873) 2025-04-23 14:10:53 -04:00
Robert Brennan 20a60633b2 Convert terminal to tab, make terminal read only (#7795)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-23 14:10:53 -04:00
Xingyao Wang c949e62fe9 refactor(mcp): simplify MCP config & fix timeout (#7820)
Co-authored-by: ducphamle2 <ducphamle212@gmail.com>
Co-authored-by: trungbach <trunga2k29@gmail.com>
Co-authored-by: quangdz1704 <Ntq.1704@gmail.com>
Co-authored-by: Duc Pham <44611780+ducphamle2@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-23 14:03:09 -04:00
openhands 41b18994fc Only use saved URL for redirect when on generic pages
- Modified usePostLoginRedirect to only redirect to saved URL when user is on a generic page (/, /login, /tos)
- Ensures that if a user is visiting a specific URL path, they stay on that path after login
- Always clears the saved last page after login, whether redirected or not
2025-04-22 03:35:56 +00:00
openhands 368583491f Always clear last page when clicking 'Start new project'
- Updated handleEndSession in sidebar.tsx to always clear the last page
- Modified useEndSession hook to always clear the last page when preserveLastPage is false
- Ensures users are redirected to the root page with no saved state when starting a new project
2025-04-22 03:19:22 +00:00
openhands 600b3157b9 Fix conversation URL handling during login flow
- Prevent error message when accessing conversation URL while logged out
- Preserve conversation URL during login process
- Redirect back to conversation URL after successful login
2025-04-22 03:13:07 +00:00
openhands 47109d9551 Implement redirect to original URL after login 2025-04-22 02:48:01 +00:00
openhands 169cb80d70 Preserve openhandsLastPage in localStorage when session times out 2025-04-22 02:38:33 +00:00
openhands 3e9e538580 Clear openhandsLastPage from localStorage on logout and new project 2025-04-22 02:23:47 +00:00
Chuck Butkus e68970e2e5 Update navigation after TOS acceptance. 2025-04-16 12:35:04 -04:00
openhands 99351a6f58 Fix: Use separate QueryClient for TOS page to prevent API calls 2025-04-16 07:08:17 +00:00
openhands 37732752b5 Fix useDisableApiOnTos hook to work outside Router context 2025-04-16 07:07:58 +00:00
openhands cd248684f7 Fix TOS page infinite loop: block API calls and prevent auth-modal 2025-04-16 07:05:11 +00:00
openhands 15c059990c Fix: Block all API calls on TOS page using axios request interceptor 2025-04-16 07:04:11 +00:00
openhands 9275be4182 Fix TOS page issues: prevent API calls and auth-modal infinite loop 2025-04-16 06:56:01 +00:00
openhands 0d116e31dc Fix: Prevent infinite API calls on TOS page by using direct axios call 2025-04-16 06:54:17 +00:00
openhands 3eb4d9c36c Fix: Prevent infinite loop between auth modal and TOS page 2025-04-16 06:43:33 +00:00
openhands ca8f5decee Fix: Prevent auth-modal from appearing on TOS page to avoid infinite login loop 2025-04-16 06:40:58 +00:00
openhands d79413bde4 Fix: Add TOS route to frontend routing configuration to resolve 404 error during login 2025-04-16 06:23:46 +00:00
Chuck Butkus 9501310244 Add TOS and save last page 2025-04-15 23:46:25 -04:00
chuckbutkus 9821653aca Merge branch 'main' into gitlab-UI-changes 2025-04-15 20:36:34 -04:00
openhands 9cb51891f1 Navigate to root page and refresh after logout 2025-04-12 00:48:40 -04:00
Rohit Malhotra 7284a95960 [Fix]: Use better auth header for GitLab microagent (#7828) 2025-04-11 22:32:26 -04:00
sp.wack 55bd40cef0 fix(backend): Return 400 if trying to open a binary file (#7825) 2025-04-11 22:32:26 -04:00
Rohit Malhotra e4b399449a (Chore): Rm single provider legacy code (#7821)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-11 22:32:26 -04:00
Panduka Muditha 1b0646f1ef feat: CLI enhancements to support /init, /help and /exit (#7801)
Co-authored-by: Bashwara Undupitiya <bashwarau@verdentra.com>
2025-04-11 22:32:26 -04:00
Graham Neubig 6b5a901143 Add extensive typing to openhands/runtime/plugins directory (#7726)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-11 22:32:26 -04:00
Engel Nyst ceddc24d98 [evaluation] fix missing metadata (#7819) 2025-04-11 22:32:26 -04:00
sp.wack 3731511ac2 chore: Improve pre-commit (#7818) 2025-04-11 22:32:26 -04:00
Calvin Smith 2e7dba7340 fix: Disable prompt caching in default condenser (#7781)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
2025-04-11 22:32:26 -04:00
Ray Myers 4b7632012b chore - Rebuild docker image in fork CI instead of using artifacts (#7809) 2025-04-11 22:32:26 -04:00
Ray Myers 6d7f300d1b Revert "chore - User docker cache mount for vscode server archive (#7… (#7817) 2025-04-11 22:32:26 -04:00
dependabot[bot] ffc60d2bef chore(deps): bump the version-all group with 5 updates (#7814)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-11 22:32:26 -04:00
Xingyao Wang c5c1be1e18 Fix: Display accumulated token usage in frontend metrics (#7803)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-04-11 22:32:26 -04:00
openhands fdb5253a52 Update AuthModal text: change sign-in labels to login labels 2025-04-11 16:29:06 +00:00
Chuck Butkus d409835377 Fix provider list 2025-04-11 02:23:18 -04:00
Chuck Butkus 714ccda10b Fix provider parsing 2025-04-11 02:06:50 -04:00
openhands 62d4a73eba Update vite.config.ts to allow all hosts 2025-04-11 05:17:07 +00:00
openhands 501dc1b35d Fix URL error in tests by adding try-catch block 2025-04-11 05:14:25 +00:00
openhands 37ca5c97f5 Make auth URL generation more generic to support multiple identity providers 2025-04-11 04:45:10 +00:00
Chuck Butkus ee11aea9f0 Merge branch 'main' into gitlab-UI-changes 2025-04-10 22:54:15 -04:00
Chuck Butkus dbd59e34dc Remove debug logging 2025-04-10 22:52:03 -04:00
Chuck Butkus 938595cc12 More debug logging 2025-04-10 12:52:33 -04:00
Chuck Butkus 56f6d1ea28 Update 2025-04-10 03:47:33 -04:00
Chuck Butkus a891d13049 Add debug logging 2025-04-10 03:28:42 -04:00
Chuck Butkus 62f8095153 Change client name and add IDP hint 2025-04-10 02:52:18 -04:00
229 changed files with 8635 additions and 15178 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ on:
LLM_MODEL:
required: false
type: string
default: "anthropic/claude-3-7-sonnet-20250219"
default: "anthropic/claude-3-5-sonnet-20241022"
LLM_API_VERSION:
required: false
type: string
-13
View File
@@ -39,7 +39,6 @@ ifeq ($(INSTALL_DOCKER),)
@$(MAKE) -s check-docker
endif
@$(MAKE) -s check-poetry
@$(MAKE) -s check-tmux
@echo "$(GREEN)Dependencies checked successfully.$(RESET)"
check-system:
@@ -102,18 +101,6 @@ check-docker:
exit 1; \
fi
check-tmux:
@echo "$(YELLOW)Checking tmux installation...$(RESET)"
@if command -v tmux > /dev/null; then \
echo "$(BLUE)$(shell tmux -V) is already installed.$(RESET)"; \
else \
echo "$(YELLOW)╔════════════════════════════════════════════════════════════════════════════╗$(RESET)"; \
echo "$(YELLOW)║ OPTIONAL: tmux is not installed. ║$(RESET)"; \
echo "$(YELLOW)║ Some advanced terminal features may not work without tmux. ║$(RESET)"; \
echo "$(YELLOW)║ You can install it if needed, but it's not required for development. ║$(RESET)"; \
echo "$(YELLOW)╚════════════════════════════════════════════════════════════════════════════╝$(RESET)"; \
fi
check-poetry:
@echo "$(YELLOW)Checking Poetry installation...$(RESET)"
@if command -v poetry > /dev/null; then \
+3 -3
View File
@@ -61,8 +61,8 @@ RUN add-apt-repository ppa:deadsnakes/ppa \
&& apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip \
&& ln -s /usr/bin/python3.12 /usr/bin/python
# NodeJS >= 22.x
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
# NodeJS >= 18.17.1
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs
# Poetry >= 1.8
@@ -108,7 +108,7 @@ WORKDIR /app
# cache build dependencies
RUN \
--mount=type=bind,source=./,target=/app/,rw \
--mount=type=bind,source=./,target=/app/ \
<<EOF
#!/bin/bash
make -s clean
-1
View File
@@ -3,7 +3,6 @@
# Production
/build
/static/swagger-ui
# Generated files
.docusaurus
-6
View File
@@ -36,7 +36,6 @@ const config: Config = {
mermaid: true,
},
themes: ['@docusaurus/theme-mermaid'],
plugins: [],
presets: [
[
'classic',
@@ -76,11 +75,6 @@ const config: Config = {
position: 'left',
label: 'User Guides',
},
{
href: 'https://docs.all-hands.dev/swagger-ui/', // FIXME: this should be a relative path, but docusarus steals the click
label: 'API',
position: 'left',
},
{
type: 'localeDropdown',
position: 'left',
-102
View File
@@ -1,102 +0,0 @@
const fs = require('fs');
const path = require('path');
const swaggerUiDist = require('swagger-ui-dist');
/**
* This script manually sets up Swagger UI for the Docusaurus documentation.
*
* Why we need this approach:
* 1. Docusaurus doesn't have a built-in way to integrate Swagger UI
* 2. We need to copy the necessary files from swagger-ui-dist to our static directory
* 3. We need to create a custom index.html file that points to our OpenAPI spec
* 4. This approach allows us to customize the Swagger UI to match our documentation style
*/
// Get the absolute path to the swagger-ui-dist package
const swaggerUiDistPath = swaggerUiDist.getAbsoluteFSPath();
// Create the target directory if it doesn't exist
const targetDir = path.join(__dirname, 'static', 'swagger-ui');
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
// Copy all files from swagger-ui-dist to our target directory
const files = fs.readdirSync(swaggerUiDistPath);
files.forEach(file => {
const sourcePath = path.join(swaggerUiDistPath, file);
const targetPath = path.join(targetDir, file);
// Skip directories and non-essential files
if (fs.statSync(sourcePath).isDirectory() ||
file === 'package.json' ||
file === 'README.md' ||
file.endsWith('.map')) {
return;
}
fs.copyFileSync(sourcePath, targetPath);
});
// Create a custom index.html file that points to our OpenAPI spec
const indexHtml = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>OpenHands API Documentation</title>
<link rel="stylesheet" type="text/css" href="./swagger-ui.css" />
<link rel="icon" type="image/png" href="./favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="./favicon-16x16.png" sizes="16x16" />
<style>
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
overflow-y: scroll;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
margin: 0;
background: #fafafa;
}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="./swagger-ui-bundle.js" charset="UTF-8"> </script>
<script src="./swagger-ui-standalone-preset.js" charset="UTF-8"> </script>
<script>
window.onload = function() {
// Begin Swagger UI call region
const ui = SwaggerUIBundle({
url: "/openapi.json",
dom_id: '#swagger-ui',
deepLinking: true,
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIStandalonePreset
],
plugins: [
SwaggerUIBundle.plugins.DownloadUrl
],
layout: "StandaloneLayout"
});
// End Swagger UI call region
window.ui = ui;
};
</script>
</body>
</html>
`;
fs.writeFileSync(path.join(targetDir, 'index.html'), indexHtml);
console.log('Swagger UI files generated successfully in static/swagger-ui/');
@@ -4,7 +4,7 @@
Microagentes públicos são diretrizes especializadas acionadas por palavras-chave para todos os usuários do OpenHands.
Eles são definidos em arquivos markdown no diretório
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge).
Microagentes públicos:
- Monitoram comandos recebidos em busca de suas palavras-chave de acionamento.
@@ -15,7 +15,7 @@ Microagentes públicos:
## Microagentes Públicos Atuais
Para mais informações sobre microagentes específicos, consulte seus arquivos de documentação individuais no
diretório [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
diretório [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/).
### Agente GitHub
**Arquivo**: `github.md`
@@ -59,7 +59,7 @@ yes | npm install package-name
## Contribuindo com um Microagente Público
Você pode criar seus próprios microagentes públicos adicionando novos arquivos markdown ao
diretório [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
diretório [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/).
### Melhores Práticas para Microagentes Públicos
@@ -81,7 +81,7 @@ Antes de criar um microagente público, considere:
#### 2. Crie o Arquivo
Crie um novo arquivo markdown em [`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/)
Crie um novo arquivo markdown em [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge/)
com um nome descritivo (por exemplo, `docker.md` para um agente focado em Docker).
Atualize o arquivo com o frontmatter necessário [de acordo com o formato exigido](./microagents-overview#microagent-format)
+10 -18
View File
@@ -8,22 +8,18 @@ OpenHands Cloud can be accessed at https://app.all-hands.dev/.
## Getting Started
After visiting OpenHands Cloud, you will be asked to connect with your GitHub or GitLab account:
1. After reading and accepting the terms of service, click `Log in with GitHub` or `Log in with GitLab`.
After visiting OpenHands Cloud, you will be asked to connect with your GitHub account:
1. After reading and accepting the terms of service, click `Connect to GitHub`.
2. Review the permissions requested by OpenHands and then click `Authorize OpenHands AI`.
- OpenHands will require some permissions from your GitHub or GitLab account. To read more about these permissions:
- GitHub: You can click the `Learn more` link on the GitHub authorize page.
- GitLab: You can expand each permission request on the GitLab authorize page.
- OpenHands will require some permissions from your GitHub account. To read more about these permissions,
you can click the `Learn more` link on the GitHub authorize page.
## Repository Access
### GitHub
#### Adding Repository Access
### Adding Repository Access
You can grant OpenHands specific repository access:
1. Click `Add GitHub repos` on the Home page.
1. Click the `Select a Git project` dropdown, select `Add more repositories...`.
2. Select the organization, then choose the specific repositories to grant OpenHands access to.
<details>
<summary>Permission Details for Repository Access</summary>
@@ -46,15 +42,11 @@ You can grant OpenHands specific repository access:
3. Click on `Install & Authorize`.
#### Modifying Repository Access
### Modifying Repository Access
You can modify GitHub repository access at any time by:
* Using the same `Add GitHub repos` workflow, or
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `Git Settings` section.
### GitLab
When using your GitLab account, OpenHands will automatically have access to your repositories.
You can modify repository access at any time by:
* Using the same `Select a Git project > Add more repositories` workflow, or
* Visiting the Settings page and selecting `Configure GitHub Repositories` under the `GitHub Settings` section.
## Conversation Persistence
@@ -1,17 +1,18 @@
# Repository Customization
You can customize how OpenHands interacts with your repository by creating a
You can customize how OpenHands works with your repository by creating a
`.openhands` directory at the root level.
## Microagents
Microagents allow you to extend OpenHands prompts with information specific to your project and define how OpenHands
should function. See [Microagents Overview](../prompting/microagents-overview) for more information.
You can use microagents to extend the OpenHands prompts with information
about your project and how you want OpenHands to work. See
[Repository Microagents](../prompting/microagents-repo) for more information.
## Setup Script
You can add a `.openhands/setup.sh` file, which will run every time OpenHands begins working with your repository.
This is an ideal location for installing dependencies, setting environment variables, and performing other setup tasks.
You can add `.openhands/setup.sh`, which will be run every time OpenHands begins
working with your repository. This is a good place to install dependencies, set
environment variables, etc.
For example:
```bash
+1 -4
View File
@@ -6,9 +6,6 @@
- Displays the conversation between the user and OpenHands.
- OpenHands explains its actions in this panel.
### Changes
- Shows the file changes performed by OpenHands.
### Workspace
- Browse project files and directories.
- Use the `Open in VS Code` option to:
@@ -23,7 +20,7 @@
- Particularly handy when using OpenHands to perform data visualization tasks.
### App
- Displays the web server when OpenHands runs an application.
- Shows the web server when OpenHands runs an application.
- Users can interact with the running application.
### Browser
@@ -1,49 +0,0 @@
# Keyword-Triggered Microagents
## Purpose
Keyword-triggered microagents provide OpenHands with specific instructions that are activated when certain keywords
appear in the prompt. This is useful for tailoring behavior based on particular tools, languages, or frameworks.
## Microagent File
Create a keyword-triggered microagent (example: `.openhands/microagents/trigger-keyword.md`) to include instructions
that activate only for prompts with specific keywords.
## Frontmatter Syntax
Frontmatter is required for keyword-triggered microagents. It must be placed at the top of the file,
above the guidelines.
Enclose the frontmatter in triple dashes (---) and include the following fields:
| Field | Description | Required | Default |
|------------|--------------------------------------------------|----------|------------------|
| `name` | A unique identifier for the microagent. | Yes | 'default' |
| `type` | Type of microagent. Must be set to `knowledge`. | Yes | 'repo' |
| `triggers` | A list of keywords that activate the microagent. | Yes | None |
| `agent` | The agent this microagent applies to. | No | 'CodeActAgent' |
## Example
```
---
name: magic_word
type: knowledge
triggers:
- yummyhappy
- happyyummy
agent: CodeActAgent
---
The user has said the magic word. Respond with "That was delicious!"
```
Keyword-triggered microagents:
- Monitor incoming prompts for specified trigger words.
- Activate when relevant triggers are detected.
- Apply their specialized knowledge and capabilities.
- Follow defined guidelines and restrictions.
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
@@ -1,40 +1,31 @@
# Microagents Overview
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge.
They provide expert guidance, automate common tasks, and ensure consistent practices across projects.
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge, repository-specific context
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
consistent practices across projects.
## Microagent Types
## Microagent Categories
Currently OpenHands supports the following types of microagents:
Currently OpenHands supports two categories of microagents:
- [General Repository Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
- [Repository-specific Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
- [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
add `<microagent_name>.md` files inside.
A microagent is classified as repository-specific or public depending on its location:
:::note
Loaded microagents take up space in the context window.
These microagents, alongside user messages, inform OpenHands about the task and the environment.
:::
- Repository-specific microagents are located in a repository's `.openhands/microagents/` directory
- Public microagents are located in the official OpenHands repository inside the `/microagents` folder
Example repository structure:
When OpenHands works with a repository, it:
```
some-repository/
└── .openhands/
└── microagents/
└── repo.md # General repository guidelines
└── trigger_this.md # Microagent triggered by specific keywords
└── trigger_that.md # Microagent triggered by specific keywords
```
1. Loads **repository-specific** microagents from `.openhands/microagents/` if present in the repository.
2. Loads **public knowledge** microagents triggered by keywords in conversations
3. Loads **public tasks** microagents when explicitly requested by the user
## Microagents Frontmatter Requirements
You can check out the existing public microagents at the [official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
Each microagent file may include frontmatter that provides additional information. In some cases, this frontmatter
is required:
## Microagent Format
| Microagent Type | Frontmatter Requirement |
|----------------------------------|-------------------------------------------------------|
| `General Repository Microagents` | Required only if more than one of this type exists. |
| `Keyword-Triggered Microagents` | Required. |
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands activate them.
Check out the [syntax documentation](./microagents-syntax) for a comprehensive guide on how to configure your microagents.
@@ -1,16 +1,35 @@
# Global Microagents
# Public Microagents
## Overview
Global microagents are [keyword-triggered microagents](./microagents-keyword) that apply to all OpenHands users.
Public microagents provide specialized context and capabilities for all OpenHands users, regardless of their repository configuration. Unlike repository-specific microagents, public microagents are globally available across all repositories.
## Contributing a Global Microagent
Public microagents come in two types:
You can create global microagents and share with the community by opening a pull request to the official repository.
- **Knowledge microagents**: Automatically activated when keywords in conversations match their triggers
- **Task microagents**: Explicitly invoked by users to guide through specific workflows
Both types follow the same syntax and structure as repository-specific microagents, using markdown files with YAML frontmatter that define their behavior and capabilities. They are located in the official OpenHands repository under:
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
Public microagents:
- Monitor incoming commands for their trigger words.
- Activate when relevant triggers are detected.
- Apply their specialized knowledge and capabilities.
- Follow their specific guidelines and restrictions.
When loading public microagents, OpenHands scans the official repository's microagents directories recursively, processing all markdown files except README.md. The system categorizes each microagent based on its `type` field in the YAML frontmatter, regardless of its exact file location within the knowledge or tasks directories.
## Contributing a Public Microagent
You can create public microagents and share with the community by opening a pull request to the official repository.
See the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) for specific instructions on how to contribute to OpenHands.
### Global Microagents Best Practices
### Public Microagents Best Practices
- **Clear Scope**: Keep the microagent focused on a specific domain or task.
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
@@ -18,11 +37,11 @@ See the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CO
- **Safety First**: Include necessary warnings and constraints.
- **Integration Awareness**: Consider how the microagent interacts with other components.
### Steps to Contribute a Global Microagent
### Steps to Contribute a Public Microagent
#### 1. Plan the Global Microagent
#### 1. Plan the Public Microagent
Before creating a global microagent, consider:
Before creating a public microagent, consider:
- What specific problem or use case will it address?
- What unique capabilities or knowledge should it have?
@@ -32,19 +51,23 @@ Before creating a global microagent, consider:
#### 2. Create File
Create a new Markdown file with a descriptive name in the appropriate directory:
[`microagents/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents)
#### 3. Testing the Global Microagent
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
- Test the agent with various prompts.
- Verify trigger words activate the agent correctly.
- Ensure instructions are clear and comprehensive.
- Check for potential conflicts and overlaps with existing agents.
Ensure it follows the correct [syntax](./microagents-syntax.md) and [best practices](./microagents-syntax.md#markdown-content-best-practices).
#### 3. Testing the Public Microagent
- Test the agent with various prompts
- Verify trigger words activate the agent correctly
- Ensure instructions are clear and comprehensive
- Check for potential conflicts and overlaps with existing agents
#### 4. Submission Process
Submit a pull request with:
- The new microagent file.
- Updated documentation if needed.
- Description of the agent's purpose and capabilities.
- The new microagent file
- Updated documentation if needed
- Description of the agent's purpose and capabilities
+104 -25
View File
@@ -1,38 +1,117 @@
# General Repository Microagents
# Repository-specific Microagents
## Purpose
## Overview
General guidelines for OpenHands to work more effectively with the repository.
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context and guidelines.
## Microagent File
This section explains how to optimize OpenHands for your project.
Create a general repository microagent (example: `.openhands/microagents/repo.md`) to include
project-specific instructions, team practices, coding standards, and architectural guidelines that are relevant for
**all** prompts in that repository.
## Creating Repository Microagents
## Frontmatter Syntax
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
The frontmatter for this type of microagent is optional, unless you plan to include more than one general
repository microagent.
You can enhance OpenHands' performance by adding custom microagents to your repository:
Frontmatter should be enclosed in triple dashes (---) and may include the following fields:
1. For overall repository-specific instructions, create a `.openhands/microagents/repo.md` file
2. For reusable domain knowledge triggered by keywords, add multiple `.md` files to `.openhands/microagents/knowledge/`
3. For common workflows and tasks, create multiple `.md` files to `.openhands/microagents/tasks/`
| Field | Description | Required | Default |
|-----------|-----------------------------------------|--------------------------------------------------------------------|----------------|
| `name` | A unique identifier for the microagent | Required only if using more than one general repository microagent | 'default' |
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
Check out the [best practices](./microagents-syntax.md#markdown-content-best-practices) for formatting the content of your custom microagent.
## Example
Keep in mind that loaded microagents take up space in the context window. It's crucial to strike a balance between the additional context provided by microagents and the instructions provided in the user's inputs.
Note that you can use OpenHands to create new microagents. The public microagent [`add_agent`](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/add_agent.md) is loaded to all OpenHands instance and can support you on this.
## Types of Microagents
OpenHands supports three primary types of microagents, each with specific purposes and features to enhance agent performance:
- [repository](#repository-microagents)
- [knowledge](#knowledge-microagents)
- [tasks](#tasks-microagents)
The standard directory structure within a repository is:
- One main `repo.md` file containing repository-specific instructions
- Additional `Knowledge` agents in `.openhands/microagents/knowledge/` directory
- Additional `Task` agents in `.openhands/microagents/tasks/` directory
When processing the `.openhands/microagents/` directory, OpenHands will recursively scan all subfolders and process any `.md` files (except `README.md`) it finds. The system determines the microagent type based on the `type` field in the YAML frontmatter, not by the file's location. However, for organizational clarity, it's recommended to follow the standard directory structure.
### Repository Microagents
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
without requiring any keyword matching or explicit call from the user.
OpenHands does not support multiple `repo.md` files in different locations or multiple microagents with type `repo`.
If you need to organize different types of repository information, the recommended approach is to use a single `repo.md` file with well-structured sections rather than trying to create multiple microagents with the type `repo`.
The best practice is to include project-specific instructions, team practices, coding standards, and architectural guidelines that are relevant for **all** prompts in that repository.
Example structure:
```
---
name: repo
---
This project is a TODO application that allows users to track TODO items.
To set it up, you can run `npm run build`.
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
your-repository/
└── .openhands/
└── microagents/
└── repo.md # Repository-specific instructions
```
[See more examples of general repository microagents here.](https://github.com/All-Hands-AI/OpenHands/tree/main/.openhands/microagents)
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
### Knowledge Microagents
Knowledge microagents provide specialized domain expertise:
- Recommended to be located in `.openhands/microagents/knowledge/`
- Triggered by specific keywords in conversations
- Contain expertise on tools, languages, frameworks, and common practices
Use knowledge microagents to trigger additional context relevant to specific technologies, tools, or workflows. For example, mentioning "git" in your conversation will automatically trigger git-related expertise to help with Git operations.
Examples structure:
```
your-repository/
└── .openhands/
└── microagents/
└── knowledge/
└── git.md
└── docker.md
└── python.md
└── ...
└── repo.md
```
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
### Tasks Microagents
Task microagents guide users through interactive workflows:
- Recommended to be located in `.openhands/microagents/tasks/`
- Provide step-by-step processes for common development tasks
- Accept inputs and adapt to different scenarios
- Ensure consistent outcomes for complex operations
Task microagents are a convenient way to store multi-step processes you perform regularly. For instance, you can create a `update_pr_description.md` microagent to automatically generate better pull request descriptions based on code changes.
Examples structure:
```
your-repository/
└── .openhands/
└── microagents/
└── tasks/
└── update_pr_description.md
└── address_pr_comments.md
└── get_test_to_pass.md
└── ...
└── knowledge/
└── ...
└── repo.md
```
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
@@ -0,0 +1,128 @@
# Microagents Syntax
Microagents are defined using markdown files with YAML frontmatter that specify their behavior, triggers, and capabilities.
Find below a comprehensive description of the frontmatter syntax and other details about how to use each type of microagent available at OpenHands.
## Frontmatter Schema
Every microagent requires a YAML frontmatter section at the beginning of the file, enclosed by triple dashes (`---`). The fields are:
| Field | Description | Required | Used By |
| ---------- | -------------------------------------------------- | ------------------------ | ---------------- |
| `name` | Unique identifier for the microagent | Yes | All types |
| `type` | Type of microagent: `repo`, `knowledge`, or `task` | Yes | All types |
| `version` | Version number (Semantic versioning recommended) | Yes | All types |
| `agent` | The agent type (typically `CodeActAgent`) | Yes | All types |
| `author` | Creator of the microagent | No | All types |
| `triggers` | List of keywords that activate the microagent | Yes for knowledge agents | Knowledge agents |
| `inputs` | Defines required user inputs for task execution | Yes for task agents | Task agents |
## Core Fields
### `agent`
**Purpose**: Specifies which agent implementation processes the microagent (typically `CodeActAgent`).
- Defines a single agent responsible for processing the microagent
- Must be available in the OpenHands system (see the [agent hub](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub))
- If the specified agent is not active, the microagent will not be used
### `triggers`
**Purpose**: Defines keywords that activate the `knowledge` microagent.
**Example**:
```yaml
triggers:
- kubernetes
- k8s
- docker
- security
- containers cluster
```
**Key points**:
- Can include both single words and multi-word phrases
- Case-insensitive matching is typically used
- More specific triggers (like "docker compose") prevent false activations
- Multiple triggers increase the chance of activation in relevant contexts
- Unique triggers like "flarglebargle" can be used for testing or special functionality
- Triggers should be carefully chosen to avoid unwanted activations or conflicts with other microagents
- Common terms used in many conversations may cause the microagent to be activated too frequently
When using multiple triggers, the microagent will be activated if any of the trigger words or phrases appear in the
conversation.
### `inputs`
**Purpose**: Defines parameters required from the user when a `task` microagent is activated.
**Schema**:
```yaml
inputs:
- name: INPUT_NAME # Used with {{ INPUT_NAME }}
description: 'Description of what this input is for'
required: true # Optional, defaults to true
```
**Key points**:
- The `name` and `description` properties are required for each input
- The `required` property is optional and defaults to `true`
- Input values are referenced in the microagent body using double curly braces (e.g., `{{ INPUT_NAME }}`)
- All inputs defined will be collected from the user before the task microagent executes
**Variable Usage**: Reference input values using double curly braces `{{ INPUT_NAME }}`.
## Example Formats
### Repository Microagent
Repository microagents provide context and guidelines for a specific repository.
- Located at: `.openhands/microagents/repo.md`
- Automatically loaded when working with the repository
- Only one per repository
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
without requiring any keyword matching or explicit call from the user.
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
### Knowledge Microagent
Provides specialized domain expertise triggered by keywords.
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
### Task Microagent
When explicitly asked by the user, will guide through interactive workflows with specific inputs.
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
## Markdown Content Best Practices
After the frontmatter, compose the microagent body using Markdown syntax. Examples of elements you can include are:
- Clear, concise instructions outlining the microagent's purpose and responsibilities
- Specific guidelines and constraints the microagent should adhere to
- Relevant code snippets and practical examples to illustrate key points
- Step-by-step procedures for task agents, guiding users through workflows
**Design Tips**:
- Keep microagents focused with a clear purpose
- Provide specific guidelines rather than general advice
- Use distinctive triggers for knowledge agents
- Keep content concise to minimize context window usage
- Break large microagents into smaller, focused ones
Aim for clarity, brevity, and practicality in your writing. Use formatting like bullet points, code blocks, and emphasis to enhance readability and comprehension.
Remember that balancing microagents details with user input space is important for maintaining effective interactions.
+19 -414
View File
@@ -24,8 +24,6 @@
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.5.1",
"swagger-cli": "^4.0.4",
"swagger-ui-dist": "^5.21.0",
"typescript": "~5.8.3"
},
"engines": {
@@ -275,273 +273,6 @@
"node": ">=6.0.0"
}
},
"node_modules/@apidevtools/openapi-schemas": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
"integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-cli": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-cli/-/swagger-cli-4.0.4.tgz",
"integrity": "sha512-hdDT3B6GLVovCsRZYDi3+wMcB1HfetTU20l2DC8zD3iFRNMC6QNAZG5fo/6PYeHWBEv7ri4MvnlKodhNB0nt7g==",
"deprecated": "This package has been abandoned. Please switch to using the actively maintained @redocly/cli",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-parser": "^10.0.1",
"chalk": "^4.1.0",
"js-yaml": "^3.14.0",
"yargs": "^15.4.1"
},
"bin": {
"swagger-cli": "bin/swagger-cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-cli/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"dev": true,
"license": "ISC"
},
"node_modules/@apidevtools/swagger-cli/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/@apidevtools/swagger-cli/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@apidevtools/swagger-methods": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
"integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
"dev": true,
"license": "MIT"
},
"node_modules/@apidevtools/swagger-parser": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.1.1.tgz",
"integrity": "sha512-u/kozRnsPO/x8QtKYJOqoGtC4kH6yg1lfYkB9Au0WhYB0FNLpyFusttQtvhlwjtG3rOwiRz4D8DnnXa8iEpIKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/json-schema-ref-parser": "11.7.2",
"@apidevtools/openapi-schemas": "^2.1.0",
"@apidevtools/swagger-methods": "^3.0.2",
"@jsdevtools/ono": "^7.1.3",
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"call-me-maybe": "^1.0.2"
},
"peerDependencies": {
"openapi-types": ">=7"
}
},
"node_modules/@apidevtools/swagger-parser/node_modules/@apidevtools/json-schema-ref-parser": {
"version": "11.7.2",
"resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.2.tgz",
"integrity": "sha512-4gY54eEGEstClvEkGnwVkTkrx0sqwemEFG5OSRRn3tD91XH0+Q8XIkYIfo7IwEWPpJZwILb9GUXeShtplRc/eA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jsdevtools/ono": "^7.1.3",
"@types/json-schema": "^7.0.15",
"js-yaml": "^4.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/philsturgeon"
}
},
"node_modules/@babel/code-frame": {
"version": "7.26.2",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz",
@@ -4104,13 +3835,6 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@jsdevtools/ono": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
"dev": true,
"license": "MIT"
},
"node_modules/@leichtgewicht/ip-codec": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
@@ -4246,14 +3970,6 @@
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==",
"license": "MIT"
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
"integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0"
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -5251,21 +4967,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-draft-04": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
"integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"ajv": "^8.5.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
@@ -5848,13 +5549,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-me-maybe": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
"integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -7498,16 +7192,6 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-named-character-reference": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz",
@@ -8916,16 +8600,6 @@
"node": ">=6.9.0"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz",
@@ -13436,16 +13110,15 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -13754,14 +13427,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openapi-types": {
"version": "12.1.3",
"resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
"integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/opener": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz",
@@ -14144,9 +13809,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.49",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==",
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
"integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==",
"funding": [
{
"type": "opencollective",
@@ -14161,11 +13826,10 @@
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"picocolors": "^1.0.0",
"source-map-js": "^1.2.0"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -15703,15 +15367,6 @@
"node": ">= 0.10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/pupa": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz",
@@ -16550,16 +16205,6 @@
"node": ">=0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -16577,13 +16222,6 @@
"node": "*"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true,
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -17064,13 +16702,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"dev": true,
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -17349,10 +16980,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"engines": {
"node": ">=0.10.0"
}
@@ -17717,32 +17347,6 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"node_modules/swagger-cli": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/swagger-cli/-/swagger-cli-4.0.4.tgz",
"integrity": "sha512-Cp8YYuLny3RJFQ4CvOBTaqmOOgYsem52dPx1xM5S4EUWFblIh2Q8atppMZvXKUr1e9xH5RwipYpmdUzdPcxWcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@apidevtools/swagger-cli": "4.0.4"
},
"bin": {
"swagger-cli": "swagger-cli.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/swagger-ui-dist": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz",
"integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@scarf/scarf": "=1.4.0"
}
},
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -18345,6 +17949,14 @@
"punycode": "^2.1.0"
}
},
"node_modules/uri-js/node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/url-loader": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/url-loader/-/url-loader-4.1.1.tgz",
@@ -18998,13 +18610,6 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"dev": true,
"license": "ISC"
},
"node_modules/widest-line": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
+4 -9
View File
@@ -4,18 +4,16 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "node generate-swagger-ui.js && docusaurus start",
"build": "node generate-swagger-ui.js && docusaurus build",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"generate-swagger-ui": "node generate-swagger-ui.js"
"typecheck": "tsc"
},
"// Note": "The OpenAPI spec is stored in docs/static/openapi.json so it's accessible at /openapi.json in the deployed site",
"dependencies": {
"@docusaurus/core": "^3.7.0",
"@docusaurus/plugin-content-pages": "^3.7.0",
@@ -33,8 +31,6 @@
"@docusaurus/module-type-aliases": "^3.5.1",
"@docusaurus/tsconfig": "^3.7.0",
"@docusaurus/types": "^3.5.1",
"swagger-cli": "^4.0.4",
"swagger-ui-dist": "^5.21.0",
"typescript": "~5.8.3"
},
"browserslist": {
@@ -51,6 +47,5 @@
},
"engines": {
"node": ">=18.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
}
+5 -5
View File
@@ -66,18 +66,18 @@ const sidebars: SidebarsConfig = {
},
{
type: 'doc',
label: 'General Repository Microagents',
label: 'Repository-specific',
id: 'usage/prompting/microagents-repo',
},
{
type: 'doc',
label: 'Keyword-Triggered Microagents',
id: 'usage/prompting/microagents-keyword',
label: 'Public',
id: 'usage/prompting/microagents-public',
},
{
type: 'doc',
label: 'Global Microagents',
id: 'usage/prompting/microagents-public',
label: 'Syntax',
id: 'usage/prompting/microagents-syntax',
},
],
},
-15
View File
@@ -1,15 +0,0 @@
# Static Files for OpenHands Documentation
This directory contains static files that are copied directly to the build output of the Docusaurus documentation.
## OpenAPI Specification
The `openapi.json` file in this directory is the OpenAPI specification for the OpenHands API. It is copied to the build output and is accessible at `/openapi.json` in the deployed site.
This file is used by the Swagger UI interface, which is accessible at `/swagger-ui/` in the deployed site.
## Why is the OpenAPI spec in the static directory?
The OpenAPI specification is placed in the static directory so that it's accessible at a predictable URL in the deployed site. This allows the Swagger UI to reference it directly.
We only need one copy of the OpenAPI spec file, which is this one in the static directory.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

-2085
View File
File diff suppressed because it is too large Load Diff
+178 -450
View File
File diff suppressed because it is too large Load Diff
-7
View File
@@ -1,10 +1,3 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run check-unlocalized-strings
npx lint-staged
# Run backend pre-commit
echo "Running backend pre-commit..."
cd ..
pre-commit run --files openhands/**/* evaluation/**/* tests/**/* --show-diff-on-failure --config ./dev_config/python/.pre-commit-config.yaml
@@ -46,12 +46,10 @@ describe("HomeHeader", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
undefined,
undefined,
[],
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
@@ -154,8 +154,8 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown"),
const dropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown")
);
await userEvent.click(dropdown);
@@ -164,7 +164,6 @@ describe("RepoConnector", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
"gui",
{
full_name: "rbren/polaris",
git_provider: "github",
@@ -174,7 +173,6 @@ describe("RepoConnector", () => {
undefined,
[],
undefined,
undefined,
);
});
@@ -11,13 +11,18 @@ import { AuthProvider } from "#/context/auth-context";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import * as GitService from "#/api/git";
import { GitRepository } from "#/types/git";
import {
getFailingChecksPrompt,
getMergeConflictPrompt,
getOpenIssuePrompt,
getUnresolvedCommentsPrompt,
} from "#/components/features/home/tasks/get-prompt-for-query";
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
};
const MOCK_TASK_2: SuggestedTask = {
@@ -25,7 +30,6 @@ const MOCK_TASK_2: SuggestedTask = {
repo: "repo2",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
};
const MOCK_TASK_3: SuggestedTask = {
@@ -33,7 +37,6 @@ const MOCK_TASK_3: SuggestedTask = {
repo: "repo3",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "gitlab",
};
const MOCK_TASK_4: SuggestedTask = {
@@ -41,7 +44,6 @@ const MOCK_TASK_4: SuggestedTask = {
repo: "repo4",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "gitlab",
};
const MOCK_RESPOSITORIES: GitRepository[] = [
@@ -95,7 +97,7 @@ describe("TaskCard", () => {
expect(createConversationSpy).toHaveBeenCalled();
});
describe("creating suggested task conversation", () => {
describe("creating conversation prompts", () => {
beforeEach(() => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
GitService,
@@ -107,7 +109,7 @@ describe("TaskCard", () => {
});
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
it("should call create conversation with the merge conflict prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_1);
@@ -116,12 +118,58 @@ describe("TaskCard", () => {
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
"suggested_task",
MOCK_RESPOSITORIES[0],
undefined,
getMergeConflictPrompt(MOCK_TASK_1.issue_number, MOCK_TASK_1.repo),
[],
undefined,
);
});
it("should call create conversation with the failing checks prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_2);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[1],
getFailingChecksPrompt(MOCK_TASK_2.issue_number, MOCK_TASK_2.repo),
[],
undefined,
);
});
it("should call create conversation with the unresolved comments prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_3);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[2],
getUnresolvedCommentsPrompt(MOCK_TASK_3.issue_number, MOCK_TASK_3.repo),
[],
undefined,
);
});
it("should call create conversation with the open issue prompt", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderTaskCard(MOCK_TASK_4);
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledWith(
MOCK_RESPOSITORIES[3],
getOpenIssuePrompt(MOCK_TASK_4.issue_number, MOCK_TASK_4.repo),
[],
undefined,
MOCK_TASK_1,
);
});
});
@@ -0,0 +1,28 @@
import { screen } from "@testing-library/react";
import { renderWithProviders } from "test-utils";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
const FILES = ["file-1-1.ts", "folder-1-2"];
describe.skip("ExplorerTree", () => {
afterEach(() => {
vi.resetAllMocks();
});
it("should render the explorer", () => {
renderWithProviders(<ExplorerTree files={FILES} defaultOpen />);
expect(screen.getByText("file-1-1.ts")).toBeInTheDocument();
expect(screen.getByText("folder-1-2")).toBeInTheDocument();
// TODO: make sure children render
});
it("should render the explorer given the defaultExpanded prop", () => {
renderWithProviders(<ExplorerTree files={FILES} />);
expect(screen.queryByText("file-1-1.ts")).toBeInTheDocument();
expect(screen.queryByText("folder-1-2")).toBeInTheDocument();
// TODO: make sure children don't render
});
});
@@ -0,0 +1,64 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { describe, it, expect, vi, afterEach } from "vitest";
import { AgentState } from "#/types/agent-state";
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
import { FileService } from "#/api/file-service/file-service.api";
const getFilesSpy = vi.spyOn(FileService, "getFiles");
vi.mock("../../services/fileService", async () => ({
uploadFiles: vi.fn(),
}));
const renderFileExplorerWithRunningAgentState = () =>
renderWithProviders(<FileExplorer isOpen onToggle={() => {}} />, {
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,
},
},
});
describe.skip("FileExplorer", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should get the workspace directory", async () => {
renderFileExplorerWithRunningAgentState();
expect(await screen.findByText("folder1")).toBeInTheDocument();
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
});
it("should refetch the workspace when clicking the refresh button", async () => {
const user = userEvent.setup();
renderFileExplorerWithRunningAgentState();
expect(await screen.findByText("folder1")).toBeInTheDocument();
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
const refreshButton = screen.getByTestId("refresh");
await user.click(refreshButton);
expect(getFilesSpy).toHaveBeenCalledTimes(2); // once for root, once for refresh button
});
it("should toggle the explorer visibility when clicking the toggle button", async () => {
const user = userEvent.setup();
renderFileExplorerWithRunningAgentState();
const folder1 = await screen.findByText("folder1");
expect(folder1).toBeInTheDocument();
const toggleButton = screen.getByTestId("toggle");
await user.click(toggleButton);
expect(folder1).toBeInTheDocument();
expect(folder1).not.toBeVisible();
});
});
@@ -0,0 +1,110 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { vi, describe, afterEach, it, expect } from "vitest";
import TreeNode from "#/components/features/file-explorer/tree-node";
import { FileService } from "#/api/file-service/file-service.api";
const getFileSpy = vi.spyOn(FileService, "getFile");
const getFilesSpy = vi.spyOn(FileService, "getFiles");
vi.mock("../../services/fileService", async () => ({
uploadFile: vi.fn(),
}));
describe.skip("TreeNode", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render a file if property has no children", () => {
renderWithProviders(<TreeNode path="/file.ts" defaultOpen />);
expect(screen.getByText("file.ts")).toBeInTheDocument();
});
it("should render a folder if it's in a subdir", async () => {
renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
expect(await screen.findByText("folder1")).toBeInTheDocument();
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
});
it("should close a folder when clicking on it", async () => {
const user = userEvent.setup();
renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
const folder1 = await screen.findByText("folder1");
const file2 = await screen.findByText("file2.ts");
expect(folder1).toBeInTheDocument();
expect(file2).toBeInTheDocument();
await user.click(folder1);
expect(folder1).toBeInTheDocument();
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
});
it("should open a folder when clicking on it", async () => {
const user = userEvent.setup();
renderWithProviders(<TreeNode path="/folder1/" />);
const folder1 = await screen.findByText("folder1");
expect(folder1).toBeInTheDocument();
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
await user.click(folder1);
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
expect(folder1).toBeInTheDocument();
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
});
it("should call `OpenHands.getFile` and return the full path of a file when clicking on a file", async () => {
const user = userEvent.setup();
renderWithProviders(<TreeNode path="/folder1/file2.ts" defaultOpen />);
const file2 = screen.getByText("file2.ts");
await user.click(file2);
expect(getFileSpy).toHaveBeenCalledWith("/folder1/file2.ts");
});
it("should render the full explorer given the defaultOpen prop", async () => {
const user = userEvent.setup();
renderWithProviders(<TreeNode path="/" defaultOpen />);
expect(getFilesSpy).toHaveBeenCalledWith("/");
const file1 = await screen.findByText("file1.ts");
const folder1 = await screen.findByText("folder1");
expect(file1).toBeInTheDocument();
expect(folder1).toBeInTheDocument();
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
await user.click(folder1);
expect(getFilesSpy).toHaveBeenCalledWith("folder1/");
expect(file1).toBeInTheDocument();
expect(folder1).toBeInTheDocument();
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
});
it("should render all children as collapsed when defaultOpen is false", async () => {
renderWithProviders(<TreeNode path="/folder1/" defaultOpen={false} />);
const folder1 = await screen.findByText("folder1");
expect(folder1).toBeInTheDocument();
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
await userEvent.click(folder1);
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
expect(folder1).toBeInTheDocument();
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
});
});
@@ -1,291 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
describe("Content", () => {
it("should render the screen", () => {
renderAppSettingsScreen();
screen.getByTestId("app-settings-screen");
});
it("should render the correct default values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
});
renderAppSettingsScreen();
await waitFor(() => {
const language = screen.getByTestId("language-input");
const analytics = screen.getByTestId("enable-analytics-switch");
const sound = screen.getByTestId("enable-sound-notifications-switch");
expect(language).toHaveValue("Norsk");
expect(analytics).toBeChecked();
expect(sound).toBeChecked();
});
});
it("should render the language options", async () => {
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
AvailableLanguages.forEach((lang) => {
const option = screen.getByText(lang.label);
expect(option).toBeInTheDocument();
});
});
});
describe("Form submission", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should submit the form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const language = await screen.findByTestId("language-input");
const analytics = await screen.findByTestId("enable-analytics-switch");
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
expect(language).toHaveValue("English");
expect(analytics).not.toBeChecked();
expect(sound).not.toBeChecked();
// change language
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(language).toHaveValue("Norsk");
// toggle options
await userEvent.click(analytics);
expect(analytics).toBeChecked();
await userEvent.click(sound);
expect(sound).toBeChecked();
// submit the form
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
language: "no",
user_consents_to_analytics: true,
enable_sound_notifications: true,
}),
);
});
it("should only enable the submit button when there are changes", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
// Language check
const language = await screen.findByTestId("language-input");
await userEvent.click(language);
const norsk = screen.getByText("Norsk");
await userEvent.click(norsk);
expect(submit).not.toBeDisabled();
await userEvent.click(language);
const english = screen.getByText("English");
await userEvent.click(english);
expect(submit).toBeDisabled();
// Analytics check
const analytics = await screen.findByTestId("enable-analytics-switch");
await userEvent.click(analytics);
expect(submit).not.toBeDisabled();
await userEvent.click(analytics);
expect(submit).toBeDisabled();
// Sound check
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
await userEvent.click(sound);
expect(submit).toBeDisabled();
});
it("should call handleCaptureConsents with true when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(true),
);
});
it("should call handleCaptureConsents with false when the analytics switch is toggled", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
user_consents_to_analytics: true,
});
const handleCaptureConsentsSpy = vi.spyOn(
CaptureConsent,
"handleCaptureConsent",
);
renderAppSettingsScreen();
const analytics = await screen.findByTestId("enable-analytics-switch");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(analytics);
await userEvent.click(submit);
await waitFor(() =>
expect(handleCaptureConsentsSpy).toHaveBeenCalledWith(false),
);
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
renderAppSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderAppSettingsScreen();
// Toggle setting to change
const sound = await screen.findByTestId(
"enable-sound-notifications-switch",
);
await userEvent.click(sound);
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -1,461 +0,0 @@
import { render, screen, waitFor } from "@testing-library/react";
import { createRoutesStub } from "react-router";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const VALID_OSS_CONFIG: GetConfigResponse = {
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const VALID_SAAS_CONFIG: GetConfigResponse = {
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
POSTHOG_CLIENT_KEY: "456",
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
},
};
const queryClient = new QueryClient();
const GitSettingsRouterStub = createRoutesStub([
{
Component: GitSettingsScreen,
path: "/settings/github",
},
]);
const renderGitSettingsScreen = () => {
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/github"]} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<QueryClientProvider client={queryClient}>
<AuthProvider>
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
</AuthProvider>
</QueryClientProvider>,
);
return {
...rest,
rerender: rerenderGitSettingsScreen,
};
};
beforeEach(() => {
// Since we don't recreate the query client on every test, we need to
// reset the query client before each test to avoid state leaks
// between tests.
queryClient.invalidateQueries();
});
describe("Content", () => {
it("should render", async () => {
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
});
it("should render the inputs if OSS mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
await screen.findByTestId("github-token-input");
await screen.findByTestId("github-token-help-anchor");
await screen.findByTestId("gitlab-token-input");
await screen.findByTestId("gitlab-token-help-anchor");
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
expect(
screen.queryByTestId("github-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("github-token-help-anchor"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-input"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("gitlab-token-help-anchor"),
).not.toBeInTheDocument();
});
});
it("should set '<hidden>' placeholder and indicator if the GitHub token is set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
const { rerender } = renderGitSettingsScreen();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).not.toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: true,
},
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
const githubInput = screen.getByTestId("github-token-input");
expect(githubInput).toHaveProperty("placeholder", "");
expect(
screen.queryByTestId("gh-set-token-indicator"),
).not.toBeInTheDocument();
const gitlabInput = screen.getByTestId("gitlab-token-input");
expect(gitlabInput).toHaveProperty("placeholder", "<hidden>");
expect(
screen.queryByTestId("gl-set-token-indicator"),
).toBeInTheDocument();
});
});
it("should render the 'Configure GitHub Repositories' button if SaaS mode and app slug exists", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
const { rerender } = renderGitSettingsScreen();
let button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
expect(screen.getByTestId("submit-button")).toBeInTheDocument();
expect(screen.getByTestId("disconnect-tokens-button")).toBeInTheDocument();
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
// wait until queries are resolved
expect(queryClient.isFetching()).toBe(0);
button = screen.queryByTestId("configure-github-repositories-button");
expect(button).not.toBeInTheDocument();
});
getConfigSpy.mockResolvedValue({
...VALID_SAAS_CONFIG,
APP_SLUG: "test-slug",
});
queryClient.invalidateQueries();
rerender();
await waitFor(() => {
button = screen.getByTestId("configure-github-repositories-button");
expect(button).toBeInTheDocument();
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("disconnect-tokens-button"),
).not.toBeInTheDocument();
});
});
});
describe("Form submission", () => {
it("should save the GitHub token", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const githubInput = await screen.findByTestId("github-token-input");
const submit = await screen.findByTestId("submit-button");
await userEvent.type(githubInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "test-token",
gitlab: "",
},
}),
);
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
provider_tokens: {
github: "",
gitlab: "test-token",
},
}),
);
});
it("should disable the button if there is no input", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(githubInput);
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(submit).not.toBeDisabled();
await userEvent.clear(gitlabInput);
expect(submit).toBeDisabled();
});
it("should enable a disconnect tokens button if there is at least one token set", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: false,
gitlab: false,
},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
await waitFor(() => expect(disconnectButton).toBeDisabled());
});
it("should call logout when pressing the disconnect tokens button", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const logoutSpy = vi.spyOn(OpenHands, "logout");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: true,
gitlab: false,
},
});
renderGitSettingsScreen();
const disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
await userEvent.click(disconnectButton);
expect(logoutSpy).toHaveBeenCalled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toHaveTextContent("Saving...");
expect(submit).toBeDisabled();
await waitFor(() => expect(submit).toHaveTextContent("Save"));
});
it("should disable the button after submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
renderGitSettingsScreen();
await screen.findByTestId("git-settings-screen");
const submit = await screen.findByTestId("submit-button");
expect(submit).toBeDisabled();
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(submit).toBeDisabled();
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
expect(gitlabInput).toHaveValue("test-token");
expect(submit).not.toBeDisabled();
// submit the form
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(submit).toBeDisabled());
});
});
describe("Status toasts", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderGitSettingsScreen();
// Toggle setting to change
const githubInput = await screen.findByTestId("github-token-input");
await userEvent.type(githubInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderGitSettingsScreen();
// Toggle setting to change
const gitlabInput = await screen.findByTestId("gitlab-token-input");
await userEvent.type(gitlabInput, "test-token");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
@@ -1,674 +0,0 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import LlmSettingsScreen from "#/routes/llm-settings";
import OpenHands from "#/api/open-hands";
import {
MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings,
} from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
),
});
beforeEach(() => {
vi.resetAllMocks();
resetTestHandlersMockSettings();
});
describe("Content", () => {
describe("Basic form", () => {
it("should render the basic form by default", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const basicFom = screen.getByTestId("llm-settings-form-basic");
within(basicFom).getByTestId("llm-provider-input");
within(basicFom).getByTestId("llm-model-input");
within(basicFom).getByTestId("llm-api-key-input");
within(basicFom).getByTestId("llm-api-key-help-anchor");
});
it("should render the default values if non exist", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(model).toHaveValue("claude-3-5-sonnet-20241022");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
});
});
it("should render the existing settings values", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("OpenAI");
expect(model).toHaveValue("gpt-4o");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
});
describe("Advanced form", () => {
it("should render the advanced form if the switch is toggled", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const basicForm = screen.getByTestId("llm-settings-form-basic");
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(basicForm).toBeInTheDocument();
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).toBeInTheDocument();
expect(basicForm).not.toBeInTheDocument();
const advancedForm = screen.getByTestId("llm-settings-form-advanced");
within(advancedForm).getByTestId("llm-custom-model-input");
within(advancedForm).getByTestId("base-url-input");
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
expect(
screen.queryByTestId("llm-settings-form-advanced"),
).not.toBeInTheDocument();
expect(screen.getByTestId("llm-settings-form-basic")).toBeInTheDocument();
});
it("should render the default advanced settings", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).not.toBeChecked();
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-3-5-sonnet-20241022");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
const hasAdvancedSettingsSetSpy = vi.spyOn(
AdvancedSettingsUtlls,
"hasAdvancedSettingsSet",
);
hasAdvancedSettingsSetSpy.mockReturnValue(true);
renderLlmSettingsScreen();
await waitFor(() => {
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
expect(advancedSwitch).toBeChecked();
screen.getByTestId("llm-settings-form-advanced");
});
});
it("should render existing advanced settings correctly", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await waitFor(() => {
expect(model).toHaveValue("openai/gpt-4o");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions",
);
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "<hidden>");
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
it.todo("should render an indicator if the llm api key is set");
});
describe("Form submission", () => {
it("should submit the basic form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const provider = screen.getByTestId("llm-provider-input");
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("OpenAI");
await userEvent.click(providerOption);
expect(provider).toHaveValue("OpenAI");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_api_key: "test-api-key",
}),
);
});
it("should submit the advanced form with the correct values", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.clear(model);
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
// enter base url
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
// enter api key
await userEvent.type(apiKey, "test-api-key");
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
// select agent
await userEvent.click(agent);
const agentOption = screen.getByText("CoActAgent");
await userEvent.click(agentOption);
expect(agent).toHaveValue("CoActAgent");
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "mock-invariant",
}),
);
});
it("should disable the button if there are no changes in the basic form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_api_key_set: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-basic");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-model-input");
const apiKey = screen.getByTestId("llm-api-key-input");
// select model
await userEvent.click(model);
const modelOption = screen.getByText("gpt-4o-mini");
await userEvent.click(modelOption);
expect(model).toHaveValue("gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.click(model);
const modelOption2 = screen.getByText("gpt-4o");
await userEvent.click(modelOption2);
expect(model).toHaveValue("gpt-4o");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should disable the button if there are no changes in the advanced form", async () => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
llm_model: "openai/gpt-4o",
llm_base_url: "https://api.openai.com/v1/chat/completions",
llm_api_key_set: true,
confirmation_mode: true,
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
screen.getByTestId("llm-settings-form-advanced");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
const model = screen.getByTestId("llm-custom-model-input");
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
const condensor = screen.getByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
expect(model).toHaveValue("openai/gpt-4o-mini");
expect(submitButton).not.toBeDisabled();
// reset model
await userEvent.clear(model);
expect(model).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(model, "openai/gpt-4o");
expect(model).toHaveValue("openai/gpt-4o");
expect(submitButton).toBeDisabled();
// enter base url
await userEvent.type(baseUrl, "/extra");
expect(baseUrl).toHaveValue(
"https://api.openai.com/v1/chat/completions/extra",
);
expect(submitButton).not.toBeDisabled();
await userEvent.clear(baseUrl);
expect(baseUrl).toHaveValue("");
expect(submitButton).not.toBeDisabled();
await userEvent.type(baseUrl, "https://api.openai.com/v1/chat/completions");
expect(baseUrl).toHaveValue("https://api.openai.com/v1/chat/completions");
expect(submitButton).toBeDisabled();
// set api key
await userEvent.type(apiKey, "test-api-key");
expect(apiKey).toHaveValue("test-api-key");
expect(submitButton).not.toBeDisabled();
// reset api key
await userEvent.clear(apiKey);
expect(apiKey).toHaveValue("");
expect(submitButton).toBeDisabled();
// set agent
await userEvent.clear(agent);
await userEvent.type(agent, "test-agent");
expect(agent).toHaveValue("test-agent");
expect(submitButton).not.toBeDisabled();
// reset agent
await userEvent.clear(agent);
expect(agent).toHaveValue("");
expect(submitButton).toBeDisabled();
await userEvent.type(agent, "CodeActAgent");
expect(agent).toHaveValue("CodeActAgent");
expect(submitButton).toBeDisabled();
// toggle confirmation mode
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
expect(submitButton).toBeDisabled();
// toggle memory condensor
await userEvent.click(condensor);
expect(condensor).not.toBeChecked();
expect(submitButton).not.toBeDisabled();
await userEvent.click(condensor);
expect(condensor).toBeChecked();
expect(submitButton).toBeDisabled();
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
it("should reset button state when switching between forms", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
const submitButton = screen.getByTestId("submit-button");
expect(submitButton).toBeDisabled();
// dirty the basic form
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
// dirty the advanced form
const model = screen.getByTestId("llm-custom-model-input");
await userEvent.type(model, "openai/gpt-4o");
expect(submitButton).not.toBeDisabled();
await userEvent.click(advancedSwitch);
expect(submitButton).toBeDisabled();
});
// flaky test
it.skip("should disable the button when submitting changes", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const apiKey = screen.getByTestId("llm-api-key-input");
await userEvent.type(apiKey, "test-api-key");
const submitButton = screen.getByTestId("submit-button");
await userEvent.click(submitButton);
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_api_key: "test-api-key",
}),
);
expect(submitButton).toHaveTextContent("Saving...");
expect(submitButton).toBeDisabled();
await waitFor(() => {
expect(submitButton).toHaveTextContent("Save");
expect(submitButton).toBeDisabled();
});
});
});
describe("Status toasts", () => {
describe("Basic form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
describe("Advanced form", () => {
it("should call displaySuccessToast when the settings are saved", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displaySuccessToastSpy = vi.spyOn(
ToastHandlers,
"displaySuccessToast",
);
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
await waitFor(() => expect(displaySuccessToastSpy).toHaveBeenCalled());
});
it("should call displayErrorToast when the settings fail to save", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
saveSettingsSpy.mockRejectedValue(new Error("Failed to save settings"));
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
// Toggle setting to change
const apiKeyInput = await screen.findByTestId("llm-api-key-input");
await userEvent.type(apiKeyInput, "test-api-key");
const submit = await screen.findByTestId("submit-button");
await userEvent.click(submit);
expect(saveSettingsSpy).toHaveBeenCalled();
expect(displayErrorToastSpy).toHaveBeenCalled();
});
});
});
describe("SaaS mode", () => {
it("should not render the runtime settings input in oss mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).not.toBeInTheDocument();
});
it("should render the runtime settings input in saas mode", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
});
it("should always render the runtime settings input as disabled", async () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
// @ts-expect-error - only return mode
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
});
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
await userEvent.click(advancedSwitch);
await screen.findByTestId("llm-settings-form-advanced");
const runtimeSettingsInput = screen.queryByTestId("runtime-settings-input");
expect(runtimeSettingsInput).toBeInTheDocument();
expect(runtimeSettingsInput).toBeDisabled();
});
});
@@ -1,4 +1,4 @@
import { screen, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRoutesStub } from "react-router";
@@ -7,30 +7,6 @@ import OpenHands from "#/api/open-hands";
import SettingsScreen from "#/routes/settings";
import { PaymentForm } from "#/components/features/payment/payment-form";
// Mock the i18next hook
vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"SETTINGS$NAV_GIT": "Git",
"SETTINGS$NAV_APPLICATION": "Application",
"SETTINGS$NAV_CREDITS": "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys",
"SETTINGS$NAV_LLM": "LLM",
"SETTINGS$TITLE": "Settings"
};
return translations[key] || key;
},
i18n: {
changeLanguage: vi.fn(),
},
}),
};
});
describe("Settings Billing", () => {
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
@@ -43,22 +19,18 @@ describe("Settings Billing", () => {
Component: () => <PaymentForm />,
path: "/settings/billing",
},
{
Component: () => <div data-testid="git-settings-screen" />,
path: "/settings/git",
},
],
},
]);
const renderSettingsScreen = () =>
renderWithProviders(<RoutesStub initialEntries={["/settings/billing"]} />);
renderWithProviders(<RoutesStub initialEntries={["/settings"]} />);
afterEach(() => {
vi.clearAllMocks();
});
it("should not render the credits tab if OSS mode", async () => {
it("should not render the navbar if OSS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "oss",
GITHUB_CLIENT_ID: "123",
@@ -71,12 +43,15 @@ describe("Settings Billing", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
const credits = within(navbar).queryByText("Credits");
expect(credits).not.toBeInTheDocument();
// Wait for the settings screen to be rendered
await screen.findByTestId("settings-screen");
// Then check that the navbar is not present
const navbar = screen.queryByTestId("settings-navbar");
expect(navbar).not.toBeInTheDocument();
});
it("should render the credits tab if SaaS mode and billing is enabled", async () => {
it("should render the navbar if SaaS mode", async () => {
getConfigSpy.mockResolvedValue({
APP_MODE: "saas",
GITHUB_CLIENT_ID: "123",
@@ -89,8 +64,11 @@ describe("Settings Billing", () => {
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
within(navbar).getByText("Credits");
await waitFor(() => {
const navbar = screen.getByTestId("settings-navbar");
within(navbar).getByText("Account");
within(navbar).getByText("Credits");
});
});
it("should render the billing settings if clicking the credits item", async () => {
@@ -112,6 +90,6 @@ describe("Settings Billing", () => {
await user.click(credits);
const billingSection = await screen.findByTestId("billing-settings");
expect(billingSection).toBeInTheDocument();
within(billingSection).getByText("PAYMENT$MANAGE_CREDITS");
});
});
File diff suppressed because it is too large Load Diff
@@ -11,35 +11,30 @@ const rawTasks: SuggestedTask[] = [
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
{
issue_number: 3,
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "github",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "github",
},
{
issue_number: 5,
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
];
@@ -52,14 +47,12 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo1",
title: "Task 1",
task_type: "MERGE_CONFLICTS",
git_provider: "github",
},
{
issue_number: 2,
repo: "repo1",
title: "Task 2",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
],
},
@@ -71,14 +64,12 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo2",
title: "Task 3",
task_type: "UNRESOLVED_COMMENTS",
git_provider: "github",
},
{
issue_number: 4,
repo: "repo2",
title: "Task 4",
task_type: "OPEN_ISSUE",
git_provider: "github",
},
],
},
@@ -90,7 +81,6 @@ const groupedTasks: SuggestedTaskGroup[] = [
repo: "repo3",
title: "Task 5",
task_type: "FAILING_CHECKS",
git_provider: "github",
},
],
},
@@ -7,10 +7,6 @@ describe("hasAdvancedSettingsSet", () => {
expect(hasAdvancedSettingsSet(DEFAULT_SETTINGS)).toBe(false);
});
it("should return false if an empty object", () => {
expect(hasAdvancedSettingsSet({})).toBe(false);
});
describe("should be true if", () => {
test("LLM_BASE_URL is set", () => {
expect(
@@ -30,6 +26,15 @@ describe("hasAdvancedSettingsSet", () => {
).toBe(true);
});
test("REMOTE_RUNTIME_RESOURCE_FACTOR is not default value", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
REMOTE_RUNTIME_RESOURCE_FACTOR: 999,
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({
+395 -413
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -10,44 +10,44 @@
"@heroui/react": "2.7.6",
"@microlink/react-json-view": "^1.26.1",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.5.2",
"@react-router/serve": "^7.5.2",
"@react-router/node": "^7.5.1",
"@react-router/serve": "^7.5.1",
"@react-types/shared": "^3.29.0",
"@reduxjs/toolkit": "^2.7.0",
"@stripe/react-stripe-js": "^3.6.0",
"@stripe/stripe-js": "^7.2.0",
"@tanstack/react-query": "^5.74.7",
"@stripe/stripe-js": "^7.1.0",
"@tanstack/react-query": "^5.74.4",
"@vitejs/plugin-react": "^4.4.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.9.0",
"axios": "^1.8.4",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.9.2",
"i18next": "^25.0.1",
"framer-motion": "^12.7.4",
"i18next": "^25.0.0",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.27",
"isbot": "^5.1.25",
"jose": "^6.0.10",
"lucide-react": "^0.503.0",
"lucide-react": "^0.501.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.236.7",
"posthog-js": "^1.236.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.5.1",
"react-i18next": "^15.4.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.5.2",
"react-router": "^7.5.1",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.2.0",
"vite": "^6.3.3",
"vite": "^6.3.2",
"web-vitals": "^3.5.2",
"ws": "^8.18.1"
},
@@ -82,14 +82,14 @@
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.1.1",
"@playwright/test": "^1.52.0",
"@react-router/dev": "^7.5.2",
"@react-router/dev": "^7.5.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@tanstack/eslint-plugin-query": "^5.73.3",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.15.3",
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.1",
"@types/react-highlight": "^0.12.8",
@@ -97,7 +97,7 @@
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"@vitest/coverage-v8": "^3.1.2",
"@vitest/coverage-v8": "^3.1.1",
"autoprefixer": "^10.4.21",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
@@ -39,7 +39,6 @@ const IGNORE_PATHS = [
"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
@@ -107,10 +106,6 @@ function isRawTranslationKey(str) {
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
];
function isExcludedTechnicalString(str) {
@@ -350,6 +345,7 @@ function isCommonDevelopmentString(str) {
// Check for specific error messages and UI text
if (
str === "Failed to accept TOS:" ||
str === "All data associated with this project will be lost." ||
str === "You will lose any unsaved information." ||
str ===
+26
View File
@@ -1,5 +1,31 @@
import axios from "axios";
import { saveLastPage } from "#/utils/last-page";
export const openHands = axios.create({
baseURL: `${window.location.protocol}//${import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host}`,
});
// We don't need the request interceptor anymore since we're using a separate QueryClient
// for the TOS page that disables all queries
// Add response interceptor to handle 401 and 403 errors
openHands.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Save the last page before redirecting
saveLastPage();
} else if (
error.response?.status === 403 &&
error.response?.data?.tos_not_accepted
) {
// Save the last page before redirecting to TOS
// Only redirect if we're not already on the TOS page
if (window.location.pathname !== "/tos") {
saveLastPage();
window.location.href = "/tos";
}
}
return Promise.reject(error);
},
);
+17 -6
View File
@@ -10,12 +10,10 @@ import {
GetTrajectoryResponse,
GitChangeDiff,
GitChange,
ConversationTrigger,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings } from "#/types/settings";
import { GitUser, GitRepository } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
class OpenHands {
/**
@@ -151,21 +149,17 @@ class OpenHands {
}
static async createConversation(
conversation_trigger: ConversationTrigger = "gui",
selectedRepository?: GitRepository,
initialUserMsg?: string,
imageUrls?: string[],
replayJson?: string,
suggested_task?: SuggestedTask,
): Promise<Conversation> {
const body = {
conversation_trigger,
selected_repository: selectedRepository,
selected_branch: undefined,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
replay_json: replayJson,
suggested_task,
};
const { data } = await openHands.post<Conversation>(
@@ -205,6 +199,14 @@ class OpenHands {
return data.status === 200;
}
/**
* Reset user settings in server
*/
static async resetSettings(): Promise<boolean> {
const response = await openHands.post("/api/reset-settings");
return response.status === 200;
}
static async createCheckoutSession(amount: number): Promise<string> {
const { data } = await openHands.post(
"/api/billing/create-checkout-session",
@@ -278,6 +280,15 @@ class OpenHands {
await openHands.post(endpoint);
}
/**
* Accept the Terms of Service
* @returns true if successful, false otherwise
*/
static async acceptTOS(): Promise<boolean> {
const response = await openHands.post("/api/accept_tos");
return response.status === 200;
}
static async getGitChanges(conversationId: string): Promise<GitChange[]> {
const { data } = await openHands.get<GitChange[]>(
`/api/conversations/${conversationId}/git/changes`,
+1 -1
View File
@@ -70,7 +70,7 @@ export interface AuthenticateResponse {
error?: string;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export type ConversationTrigger = "resolver" | "gui";
export interface Conversation {
conversation_id: string;
@@ -1,4 +1,4 @@
import { DiffEditor, Monaco } from "@monaco-editor/react";
import { DiffEditor } from "@monaco-editor/react";
import React from "react";
import { editor as editor_t } from "monaco-editor";
import { LuFileDiff, LuFileMinus, LuFilePlus } from "react-icons/lu";
@@ -88,29 +88,6 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
}
}, []);
const beforeMount = (monaco: Monaco) => {
monaco.editor.defineTheme("custom-diff-theme", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "6a9955" },
{ token: "keyword", foreground: "569cd6" },
{ token: "string", foreground: "ce9178" },
{ token: "number", foreground: "b5cea8" },
],
colors: {
"diffEditor.insertedTextBackground": "#014b01AA", // Stronger green background
"diffEditor.removedTextBackground": "#750000AA", // Stronger red background
"diffEditor.insertedLineBackground": "#003f00AA", // Dark green for added lines
"diffEditor.removedLineBackground": "#5a0000AA", // Dark red for removed lines
"diffEditor.border": "#444444", // Border between diff editors
"editorUnnecessaryCode.border": "#00000000", // No border for unnecessary code
"editorUnnecessaryCode.opacity": "#00000077", // Slightly faded
},
});
};
const handleEditorDidMount = (editor: editor_t.IStandaloneDiffEditor) => {
diffEditorRef.current = editor;
updateEditorHeight();
@@ -168,9 +145,8 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
language={getLanguageFromPath(filePath)}
original={isAdded ? "" : diff.original}
modified={isDeleted ? "" : diff.modified}
theme="custom-diff-theme"
theme="vs-dark"
onMount={handleEditorDidMount}
beforeMount={beforeMount}
options={{
renderValidationDecorations: "off",
readOnly: true,
@@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next";
import TreeNode from "./tree-node";
import { I18nKey } from "#/i18n/declaration";
interface ExplorerTreeProps {
files: string[] | null;
defaultOpen?: boolean;
}
export function ExplorerTree({
files,
defaultOpen = false,
}: ExplorerTreeProps) {
const { t } = useTranslation();
if (!files?.length) {
const message = !files
? I18nKey.EXPLORER$LOADING_WORKSPACE_MESSAGE
: I18nKey.EXPLORER$EMPTY_WORKSPACE_MESSAGE;
return <div className="text-sm text-gray-400 pt-4">{t(message)}</div>;
}
return (
<div className="w-full h-full pt-[4px]">
{files.map((file) => (
<TreeNode key={file} path={file} defaultOpen={defaultOpen} />
))}
</div>
);
}
@@ -0,0 +1,28 @@
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
import { cn } from "#/utils/utils";
interface ExplorerActionsProps {
onRefresh: () => void;
toggleHidden: () => void;
isHidden: boolean;
}
export function ExplorerActions({
toggleHidden,
onRefresh,
isHidden,
}: ExplorerActionsProps) {
return (
<div
className={cn(
"flex h-[24px] items-center gap-1",
isHidden ? "right-3" : "right-2",
)}
>
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
</div>
);
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { ExplorerActions } from "./file-explorer-actions";
interface FileExplorerHeaderProps {
isOpen: boolean;
onToggle: () => void;
onRefreshWorkspace: () => void;
}
export function FileExplorerHeader({
isOpen,
onToggle,
onRefreshWorkspace,
}: FileExplorerHeaderProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"sticky top-0 bg-base-secondary",
"flex items-center",
!isOpen ? "justify-center" : "justify-between",
)}
>
{isOpen && (
<div className="text-neutral-300 font-bold text-sm">
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
</div>
)}
<ExplorerActions
isHidden={!isOpen}
toggleHidden={onToggle}
onRefresh={onRefreshWorkspace}
/>
</div>
);
}
@@ -0,0 +1,97 @@
import React from "react";
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
import toast from "#/utils/toast";
import { RootState } from "#/store";
import { I18nKey } from "#/i18n/declaration";
import { useListFiles } from "#/hooks/query/use-list-files";
import { cn } from "#/utils/utils";
import { FileExplorerHeader } from "./file-explorer-header";
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
import { BrandButton } from "../settings/brand-button";
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
interface FileExplorerProps {
isOpen: boolean;
onToggle: () => void;
}
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
const { t } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { data: paths, refetch, error } = useListFiles();
const { data: vscodeUrl } = useVSCodeUrl({
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
});
const handleOpenVSCode = () => {
if (vscodeUrl?.vscode_url) {
window.open(vscodeUrl.vscode_url, "_blank");
} else if (vscodeUrl?.error) {
toast.error(
`open-vscode-error-${new Date().getTime()}`,
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
error: vscodeUrl.error,
}),
);
}
};
const refreshWorkspace = () => {
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
refetch();
}
};
React.useEffect(() => {
refreshWorkspace();
}, [curAgentState]);
return (
<div data-testid="file-explorer" className="relative h-full">
<div
className={cn(
"bg-base-secondary h-full border-r-1 border-r-neutral-600 flex flex-col",
!isOpen ? "w-12" : "w-60",
)}
>
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
<FileExplorerHeader
isOpen={isOpen}
onToggle={onToggle}
onRefreshWorkspace={refreshWorkspace}
/>
{!error && (
<div className="overflow-auto flex-grow min-h-0">
<div style={{ display: !isOpen ? "none" : "block" }}>
<ExplorerTree files={paths || []} />
</div>
</div>
)}
{error && (
<div className="flex flex-col items-center justify-center h-full">
<p className="text-neutral-300 text-sm">{error.message}</p>
</div>
)}
{isOpen && (
<BrandButton
testId="open-vscode-button"
type="button"
variant="secondary"
className="w-full text-content border-content"
isDisabled={RUNTIME_INACTIVE_STATES.includes(curAgentState)}
onClick={handleOpenVSCode}
startContent={<VSCodeIcon width={20} height={20} />}
>
{t(I18nKey.VSCODE$OPEN)}
</BrandButton>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,12 @@
import { FaFile } from "react-icons/fa";
import { getExtension } from "#/utils/utils";
import { EXTENSION_ICON_MAP } from "../../extension-icon-map.constant";
interface FileIconProps {
filename: string;
}
export function FileIcon({ filename }: FileIconProps) {
const extension = getExtension(filename);
return EXTENSION_ICON_MAP[extension] || <FaFile />;
}
@@ -0,0 +1,20 @@
import { FolderIcon } from "./folder-icon";
import { FileIcon } from "./file-icon";
interface FilenameProps {
name: string;
type: "folder" | "file";
isOpen: boolean;
}
export function Filename({ name, type, isOpen }: FilenameProps) {
return (
<div className="cursor-pointer text-nowrap rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white">
<div className="flex-shrink-0">
{type === "folder" && <FolderIcon isOpen={isOpen} />}
{type === "file" && <FileIcon filename={name} />}
</div>
<div className="flex-grow">{name}</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { FaFolder, FaFolderOpen } from "react-icons/fa";
interface FolderIconProps {
isOpen: boolean;
}
export function FolderIcon({ isOpen }: FolderIconProps) {
return isOpen ? (
<FaFolderOpen color="D9D3D0" className="icon" />
) : (
<FaFolder color="D9D3D0" className="icon" />
);
}
@@ -0,0 +1,88 @@
import React from "react";
import { useSelector } from "react-redux";
import { useFiles } from "#/context/files";
import { cn } from "#/utils/utils";
import { useListFiles } from "#/hooks/query/use-list-files";
import { useListFile } from "#/hooks/query/use-list-file";
import { Filename } from "./filename";
import { RootState } from "#/store";
interface TreeNodeProps {
path: string;
defaultOpen?: boolean;
}
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
const [isOpen, setIsOpen] = React.useState(defaultOpen);
const { curAgentState } = useSelector((state: RootState) => state.agent);
const isDirectory = path.endsWith("/");
const { data: paths } = useListFiles({
path,
enabled: isDirectory && isOpen,
});
const { data: fileContent, refetch } = useListFile({ path });
React.useEffect(() => {
if (fileContent) {
if (fileContent !== files[path]) {
setFileContent(path, fileContent);
}
}
}, [fileContent, path]);
React.useEffect(() => {
if (selectedPath === path && !isDirectory) {
refetch();
}
}, [curAgentState, selectedPath, path, isDirectory]);
const fileParts = path.split("/");
const filename =
fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
const handleClick = async () => {
if (isDirectory) setIsOpen((prev) => !prev);
else {
setSelectedPath(path);
await refetch();
}
};
return (
<div
className={cn(
"text-sm text-neutral-400",
path === selectedPath && "bg-gray-700",
)}
>
<button
type={isDirectory ? "button" : "submit"}
name="file"
value={path}
onClick={handleClick}
className="flex items-center justify-between w-full px-1"
>
<Filename
name={filename}
type={isDirectory ? "folder" : "file"}
isOpen={isOpen}
/>
</button>
{isOpen && paths && (
<div className="ml-5">
{paths.map((child, index) => (
<TreeNode key={index} path={child} />
))}
</div>
)}
</div>
);
}
export default React.memo(TreeNode);
@@ -0,0 +1,33 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { setInitialPrompt } from "#/state/initial-query-slice";
const INITIAL_PROMPT = "";
export function CodeNotInGitLink() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { mutate: createConversation } = useCreateConversation();
const handleStartFromScratch = () => {
// Set the initial prompt and create a new conversation
dispatch(setInitialPrompt(INITIAL_PROMPT));
createConversation({ q: INITIAL_PROMPT });
};
return (
<div className="text-xs text-neutral-400">
{t(I18nKey.GITHUB$CODE_NOT_IN_GITHUB)}{" "}
<span
onClick={handleStartFromScratch}
className="underline cursor-pointer"
>
{t(I18nKey.GITHUB$START_FROM_SCRATCH)}
</span>{" "}
{t(I18nKey.GITHUB$VSCODE_LINK_DESCRIPTION)}
</div>
);
}
@@ -28,7 +28,7 @@ export function HomeHeader() {
testId="header-launch-button"
variant="primary"
type="button"
onClick={() => createConversation({ conversation_trigger: "gui" })}
onClick={() => createConversation({})}
isDisabled={isCreatingConversation}
>
{!isCreatingConversation && "Launch from Scratch"}
@@ -142,12 +142,7 @@ export function RepositorySelectionForm({
isLoadingRepositories ||
isRepositoriesError
}
onClick={() =>
createConversation({
selectedRepository,
conversation_trigger: "gui",
})
}
onClick={() => createConversation({ selectedRepository })}
>
{!isCreatingConversation && "Launch"}
{isCreatingConversation && t("HOME$LOADING")}
@@ -0,0 +1,49 @@
import { SuggestedTaskType } from "./task.types";
export const getMergeConflictPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
export const getFailingChecksPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to look at the GitHub Actions that are failing on the most recent commit. Try and reproduce the failure locally.
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the GitHub actions have run again. If they are still failing, repeat the process.`;
export const getUnresolvedCommentsPrompt = (
issueNumber: number,
repo: string,
) => `You are working on Pull Request #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
Use the GitHub API to retrieve the PR details. Check out the branch from that pull request and look at the diff versus the base branch of the PR to understand the PR's intention.
Then use the GitHub API to retrieve all the feedback on the PR so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
export const getOpenIssuePrompt = (
issueNumber: number,
repo: string,
) => `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue
Use the GitHub API to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made
Finally, make the required changes and open up a pull request. Be sure to reference the issue in the PR description`;
export const getPromptForQuery = (
type: SuggestedTaskType,
issueNumber: number,
repo: string,
) => {
switch (type) {
case "MERGE_CONFLICTS":
return getMergeConflictPrompt(issueNumber, repo);
case "FAILING_CHECKS":
return getFailingChecksPrompt(issueNumber, repo);
case "UNRESOLVED_COMMENTS":
return getUnresolvedCommentsPrompt(issueNumber, repo);
case "OPEN_ISSUE":
return getOpenIssuePrompt(issueNumber, repo);
default:
return "";
}
};
@@ -4,8 +4,8 @@ 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 { getPromptForQuery } from "./get-prompt-for-query";
import { TaskIssueNumber } from "./task-issue-number";
import { Provider } from "#/types/settings";
const getTaskTypeMap = (
t: (key: string) => string,
@@ -26,37 +26,31 @@ export function TaskCard({ task }: TaskCardProps) {
const isCreatingConversation = useIsCreatingConversation();
const { t } = useTranslation();
const getRepo = (repo: string, git_provider: Provider) => {
const getRepo = (repo: string) => {
const repositoriesList = repositories?.pages.flatMap((page) => page.data);
const selectedRepo = repositoriesList?.find(
(repository) =>
repository.full_name === repo &&
repository.git_provider === git_provider,
(repository) => repository.full_name === repo,
);
return selectedRepo;
};
const handleLaunchConversation = () => {
const repo = getRepo(task.repo, task.git_provider);
const repo = getRepo(task.repo);
const query = getPromptForQuery(
task.task_type,
task.issue_number,
task.repo,
);
return createConversation({
conversation_trigger: "suggested_task",
selectedRepository: repo,
suggested_task: task,
q: query,
});
};
// Determine the correct URL format based on git provider
let href: string;
if (task.git_provider === "gitlab") {
const issueType =
task.task_type === "OPEN_ISSUE" ? "issues" : "merge_requests";
href = `https://gitlab.com/${task.repo}/-/${issueType}/${task.issue_number}`;
} else {
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
}
const hrefType = task.task_type === "OPEN_ISSUE" ? "issues" : "pull";
const href = `https://github.com/${task.repo}/${hrefType}/${task.issue_number}`;
return (
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
@@ -1,5 +1,3 @@
import { Provider } from "#/types/settings";
export type SuggestedTaskType =
| "MERGE_CONFLICTS"
| "FAILING_CHECKS"
@@ -7,7 +5,6 @@ export type SuggestedTaskType =
| "OPEN_ISSUE"; // This is a task type identifier, not a UI string
export interface SuggestedTask {
git_provider: Provider;
issue_number: number;
repo: string;
title: string;
@@ -1,15 +0,0 @@
import { InputSkeleton } from "../input-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function AppSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<InputSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
</div>
);
}
@@ -1,34 +0,0 @@
import { useTranslation } from "react-i18next";
import { AvailableLanguages } from "#/i18n";
import { I18nKey } from "#/i18n/declaration";
import { SettingsDropdownInput } from "../settings-dropdown-input";
interface LanguageInputProps {
name: string;
onChange: (value: string) => void;
defaultKey: string;
}
export function LanguageInput({
defaultKey,
onChange,
name,
}: LanguageInputProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId={name}
name={name}
onInputChange={onChange}
label={t(I18nKey.SETTINGS$LANGUAGE)}
items={AvailableLanguages.map((l) => ({
key: l.value,
label: l.label,
}))}
defaultSelectedKey={defaultKey}
isClearable={false}
wrapperClassName="w-[680px]"
/>
);
}
@@ -2,7 +2,6 @@ import { cn } from "#/utils/utils";
interface BrandButtonProps {
testId?: string;
name?: string;
variant: "primary" | "secondary" | "danger";
type: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
isDisabled?: boolean;
@@ -13,7 +12,6 @@ interface BrandButtonProps {
export function BrandButton({
testId,
name,
children,
variant,
type,
@@ -24,7 +22,6 @@ export function BrandButton({
}: React.PropsWithChildren<BrandButtonProps>) {
return (
<button
name={name}
data-testid={testId}
disabled={isDisabled}
// The type is alreadt passed as a prop to the button component
@@ -1,27 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ConfigureGitHubRepositoriesAnchorProps {
slug: string;
}
export function ConfigureGitHubRepositoriesAnchor({
slug,
}: ConfigureGitHubRepositoriesAnchorProps) {
const { t } = useTranslation();
return (
<a
data-testid="configure-github-repositories-button"
href={`https://github.com/apps/${slug}/installations/new`}
target="_blank"
rel="noreferrer noopener"
className="px-11 py-9"
>
<BrandButton type="button" variant="secondary">
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
);
}
@@ -1,18 +0,0 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
export function GitSettingInputsSkeleton() {
return (
<div className="px-11 py-9 flex flex-col gap-12">
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
<div className="flex flex-col gap-6">
<InputSkeleton />
<SubtextSkeleton />
</div>
</div>
);
}
@@ -1,30 +0,0 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitHubTokenHelpAnchor() {
return (
<p data-testid="github-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITHUB$TOKEN_HELP_TEXT}
components={[
<a
key="github-token-help-anchor-link"
aria-label="GitHub token help link"
href="https://github.com/settings/tokens/new?description=openhands-app&scopes=repo,user,workflow"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="github-token-help-anchor-link-2"
aria-label="GitHub token see more link"
href="https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,60 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitHubTokenHelpAnchor } from "./github-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitHubTokenInputProps {
onChange: (value: string) => void;
onBaseDomainChange?: (value: string) => void;
isGitHubTokenSet: boolean;
name: string;
baseDomainSet?: string | null;
isSaas: boolean;
}
export function GitHubTokenInput({
onChange,
onBaseDomainChange,
isGitHubTokenSet,
name,
baseDomainSet,
isSaas,
}: GitHubTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
{!isSaas && (
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITHUB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitHubTokenSet ? "<hidden>" : ""}
startContent={
isGitHubTokenSet && (
<KeyStatusIcon
testId="gh-set-token-indicator"
isSet={isGitHubTokenSet}
/>
)
}
/>
)}
<SettingsInput
onChange={onBaseDomainChange || (() => {})}
label={t(I18nKey.GITHUB$BASE_DOMAIN_LABEL)}
type="text"
className="w-[680px]"
placeholder={"github.com"}
defaultValue={baseDomainSet ? baseDomainSet : undefined}
/>
{!isSaas && <GitHubTokenHelpAnchor />}
</div>
);
}
@@ -1,30 +0,0 @@
import { Trans } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function GitLabTokenHelpAnchor() {
return (
<p data-testid="gitlab-token-help-anchor" className="text-xs">
<Trans
i18nKey={I18nKey.GITLAB$TOKEN_HELP_TEXT}
components={[
<a
key="gitlab-token-help-anchor-link"
aria-label="Gitlab token help link"
href="https://gitlab.com/-/user_settings/personal_access_tokens?name=openhands-app&scopes=api,read_user,read_repository,write_repository"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
<a
key="gitlab-token-help-anchor-link-2"
aria-label="GitLab token see more link"
href="https://docs.gitlab.com/user/profile/personal_access_tokens/"
target="_blank"
className="underline underline-offset-2"
rel="noopener noreferrer"
/>,
]}
/>
</p>
);
}
@@ -1,60 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { SettingsInput } from "../settings-input";
import { GitLabTokenHelpAnchor } from "./gitlab-token-help-anchor";
import { KeyStatusIcon } from "../key-status-icon";
interface GitLabTokenInputProps {
onChange: (value: string) => void;
onBaseDomainChange?: (value: string) => void;
isGitLabTokenSet: boolean;
name: string;
baseDomainSet?: string | null;
isSaas: boolean;
}
export function GitLabTokenInput({
onChange,
onBaseDomainChange,
isGitLabTokenSet,
name,
baseDomainSet,
isSaas,
}: GitLabTokenInputProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-6">
{!isSaas && (
<SettingsInput
testId={name}
name={name}
onChange={onChange}
label={t(I18nKey.GITLAB$TOKEN_LABEL)}
type="password"
className="w-[680px]"
placeholder={isGitLabTokenSet ? "<hidden>" : ""}
startContent={
isGitLabTokenSet && (
<KeyStatusIcon
testId="gl-set-token-indicator"
isSet={isGitLabTokenSet}
/>
)
}
/>
)}
<SettingsInput
onChange={onBaseDomainChange || (() => {})}
label={t(I18nKey.GITLAB$BASE_DOMAIN_LABEL)}
type="text"
className="w-[680px]"
placeholder={"gitlab.com"}
defaultValue={baseDomainSet ? baseDomainSet : undefined}
/>
{!isSaas && <GitLabTokenHelpAnchor />}
</div>
);
}
@@ -1,8 +0,0 @@
export function InputSkeleton() {
return (
<div className="flex flex-col gap-2.5">
<div className="w-[70px] h-[20px] skeleton" />
<div className="w-[680px] h-[40px] skeleton" />
</div>
);
}
@@ -2,13 +2,12 @@ import SuccessIcon from "#/icons/success.svg?react";
import { cn } from "#/utils/utils";
interface KeyStatusIconProps {
testId?: string;
isSet: boolean;
}
export function KeyStatusIcon({ testId, isSet }: KeyStatusIconProps) {
export function KeyStatusIcon({ isSet }: KeyStatusIconProps) {
return (
<span data-testid={testId || (isSet ? "set-indicator" : "unset-indicator")}>
<span data-testid={isSet ? "set-indicator" : "unset-indicator"}>
<SuccessIcon className={cn(isSet ? "text-success" : "text-danger")} />
</span>
);
@@ -1,21 +0,0 @@
import { InputSkeleton } from "../input-skeleton";
import { SubtextSkeleton } from "../subtext-skeleton";
import { SwitchSkeleton } from "../switch-skeleton";
export function LlmSettingsInputsSkeleton() {
return (
<div
data-testid="app-settings-skeleton"
className="px-11 py-9 flex flex-col gap-6"
>
<SwitchSkeleton />
<InputSkeleton />
<InputSkeleton />
<InputSkeleton />
<SubtextSkeleton />
<SwitchSkeleton />
<SwitchSkeleton />
<InputSkeleton />
</div>
);
}
@@ -1,41 +0,0 @@
import { useTranslation } from "react-i18next";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "../brand-button";
interface ResetSettingsModalProps {
onReset: () => void;
}
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop>
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
<BrandButton
testId="confirm-button"
type="submit"
name="reset-settings"
variant="primary"
className="grow"
>
Reset
</BrandButton>
<BrandButton
testId="cancel-button"
type="button"
variant="secondary"
className="grow"
onClick={onReset}
>
Cancel
</BrandButton>
</div>
</div>
</ModalBackdrop>
);
}
@@ -6,7 +6,6 @@ interface SettingsSwitchProps {
name?: string;
onToggle?: (value: boolean) => void;
defaultIsToggled?: boolean;
isToggled?: boolean;
isBeta?: boolean;
}
@@ -16,7 +15,6 @@ export function SettingsSwitch({
name,
onToggle,
defaultIsToggled,
isToggled: controlledIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
@@ -27,18 +25,17 @@ export function SettingsSwitch({
};
return (
<label className="flex items-center gap-2 w-fit cursor-pointer">
<label className="flex items-center gap-2 w-fit">
<input
hidden
data-testid={testId}
name={name}
type="checkbox"
onChange={(e) => handleToggle(e.target.checked)}
checked={controlledIsToggled ?? isToggled}
defaultChecked={defaultIsToggled}
/>
<StyledSwitchComponent isToggled={controlledIsToggled ?? isToggled} />
<StyledSwitchComponent isToggled={isToggled} />
<div className="flex items-center gap-1">
<span className="text-sm">{children}</span>
@@ -1,3 +0,0 @@
export function SubtextSkeleton() {
return <div className="w-[250px] h-[20px] skeleton" />;
}
@@ -1,8 +0,0 @@
export function SwitchSkeleton() {
return (
<div className="flex items-center gap-2">
<div className="w-[48px] h-[24px] skeleton-round" />
<div className="w-[100px] h-[20px] skeleton" />
</div>
);
}
@@ -75,7 +75,8 @@ export function Sidebar() {
const handleEndSession = () => {
dispatch(setCurrentAgentState(AgentState.LOADING));
endSession();
// Always clear the last page when starting a new project
endSession(false);
};
const handleLogout = async () => {
@@ -18,7 +18,7 @@ function Terminal() {
return (
<div className="h-full p-2 min-h-0 flex-grow">
{isRuntimeInactive && (
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
<div className="text-sm text-gray-400 mb-2">
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
)}
@@ -11,6 +11,7 @@ import GitHubLogo from "#/assets/branding/github-logo.svg?react";
import GitLabLogo from "#/assets/branding/gitlab-logo.svg?react";
import { useAuthUrl } from "#/hooks/use-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { saveLastPage } from "#/utils/last-page";
interface AuthModalProps {
githubAuthUrl: string | null;
@@ -28,6 +29,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
const handleGitHubAuth = () => {
if (githubAuthUrl) {
// Save the current path before redirecting to auth
saveLastPage();
handleCaptureConsent(true);
window.location.href = githubAuthUrl;
}
@@ -35,6 +38,8 @@ export function AuthModal({ githubAuthUrl, appMode }: AuthModalProps) {
const handleGitLabAuth = () => {
if (gitlabAuthUrl) {
// Save the current path before redirecting to auth
saveLastPage();
handleCaptureConsent(true);
window.location.href = gitlabAuthUrl;
}
@@ -13,7 +13,7 @@ export function TOSCheckbox({ onChange }: TOSCheckboxProps) {
<span>
{t(I18nKey.TOS$ACCEPT)}{" "}
<a
href="https://www.all-hands.dev/tos"
href="/tos"
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 text-blue-500 hover:text-blue-700"
+10 -14
View File
@@ -10,7 +10,6 @@ interface ContainerProps {
icon?: React.ReactNode;
isBeta?: boolean;
isLoading?: boolean;
rightContent?: React.ReactNode;
}[];
children: React.ReactNode;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
@@ -31,19 +30,16 @@ export function Container({
>
{labels && (
<div className="flex text-xs h-[36px]">
{labels.map(
({ label: l, to, icon, isBeta, isLoading, rightContent }) => (
<NavTab
key={to}
to={to}
label={l}
icon={icon}
isBeta={isBeta}
isLoading={isLoading}
rightContent={rightContent}
/>
),
)}
{labels.map(({ label: l, to, icon, isBeta, isLoading }) => (
<NavTab
key={to}
to={to}
label={l}
icon={icon}
isBeta={isBeta}
isLoading={isLoading}
/>
))}
</div>
)}
{!labels && label && (
+5 -15
View File
@@ -9,17 +9,9 @@ interface NavTabProps {
icon: React.ReactNode;
isBeta?: boolean;
isLoading?: boolean;
rightContent?: React.ReactNode;
}
export function NavTab({
to,
label,
icon,
isBeta,
isLoading,
rightContent,
}: NavTabProps) {
export function NavTab({ to, label, icon, isBeta, isLoading }: NavTabProps) {
return (
<NavLink
end
@@ -32,17 +24,15 @@ export function NavTab({
)}
>
{({ isActive }) => (
<div className="flex items-center justify-between w-full">
<>
<div className="flex items-center gap-2">
<div className={cn(isActive && "text-logo")}>{icon}</div>
{label}
{isBeta && <BetaBadge />}
</div>
<div className="flex items-center gap-2">
{rightContent}
{isLoading && <LoadingSpinner size="small" />}
</div>
</div>
{isLoading && <LoadingSpinner size="small" />}
</>
)}
</NavLink>
);
@@ -19,7 +19,7 @@ export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
/>
}
testId="refresh"
ariaLabel={t("BUTTON$REFRESH" as I18nKey)}
ariaLabel={t(I18nKey.WORKSPACE$REFRESH)}
onClick={onClick}
/>
);
@@ -0,0 +1,39 @@
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
import { useTranslation } from "react-i18next";
import { IconButton } from "./icon-button";
import { I18nKey } from "#/i18n/declaration";
interface ToggleWorkspaceIconButtonProps {
onClick: () => void;
isHidden: boolean;
}
export function ToggleWorkspaceIconButton({
onClick,
isHidden,
}: ToggleWorkspaceIconButtonProps) {
const { t } = useTranslation();
return (
<IconButton
icon={
isHidden ? (
<IoIosArrowForward
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
) : (
<IoIosArrowBack
size={20}
className="text-neutral-400 hover:text-neutral-100 transition"
/>
)
}
testId="toggle"
ariaLabel={
isHidden ? t(I18nKey.WORKSPACE$OPEN) : t(I18nKey.WORKSPACE$CLOSE)
}
onClick={onClick}
/>
);
}
@@ -14,14 +14,12 @@ interface ModelSelectorProps {
isDisabled?: boolean;
models: Record<string, { separator: string; models: string[] }>;
currentModel?: string;
onChange?: (model: string | null) => void;
}
export function ModelSelector({
isDisabled,
models,
currentModel,
onChange,
}: ModelSelectorProps) {
const [, setLitellmId] = React.useState<string | null>(null);
const [selectedProvider, setSelectedProvider] = React.useState<string | null>(
@@ -57,7 +55,6 @@ export function ModelSelector({
}
setLitellmId(fullModel);
setSelectedModel(model);
onChange?.(fullModel);
};
const clear = () => {
+1 -1
View File
@@ -52,7 +52,7 @@ export function TaskForm({ ref }: TaskFormProps) {
const formData = new FormData(event.currentTarget);
const q = formData.get("q")?.toString();
createConversation({ q, conversation_trigger: "gui" });
createConversation({ q });
};
return (
+9 -1
View File
@@ -1,11 +1,13 @@
import React from "react";
import { Provider } from "#/types/settings";
import { clearLastPage } from "../utils/last-page";
interface AuthContextType {
providerTokensSet: Provider[];
setProviderTokensSet: (tokens: Provider[]) => void;
providersAreSet: boolean;
setProvidersAreSet: (status: boolean) => void;
logout: () => void;
}
interface AuthContextProps extends React.PropsWithChildren {
@@ -28,14 +30,20 @@ function AuthProvider({
initialProvidersAreSet,
);
const logout = React.useCallback(() => {
// Clear the last page before logging out
clearLastPage();
}, []);
const value = React.useMemo(
() => ({
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
logout,
}),
[providerTokensSet],
[providerTokensSet, logout],
);
return <AuthContext value={value}>{children}</AuthContext>;
+69
View File
@@ -0,0 +1,69 @@
import React from "react";
interface FilesContextType {
/**
* List of file paths in the workspace
*/
paths: string[];
/**
* Set the list of file paths in the workspace
* @param paths The list of file paths in the workspace
* @returns void
*/
setPaths: (paths: string[]) => void;
/**
* A map of file paths to their contents
*/
files: Record<string, string>;
/**
* Set the content of a file
* @param path The path of the file
* @param content The content of the file
* @returns void
*/
setFileContent: (path: string, content: string) => void;
selectedPath: string | null;
setSelectedPath: (path: string | null) => void;
}
const FilesContext = React.createContext<FilesContextType | undefined>(
undefined,
);
interface FilesProviderProps {
children: React.ReactNode;
}
function FilesProvider({ children }: FilesProviderProps) {
const [paths, setPaths] = React.useState<string[]>([]);
const [files, setFiles] = React.useState<Record<string, string>>({});
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
const setFileContent = React.useCallback((path: string, content: string) => {
setFiles((prev) => ({ ...prev, [path]: content }));
}, []);
const value = React.useMemo(
() => ({
paths,
setPaths,
files,
setFileContent,
selectedPath,
setSelectedPath,
}),
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
);
return <FilesContext value={value}>{children}</FilesContext>;
}
function useFiles() {
const context = React.useContext(FilesContext);
if (context === undefined) {
throw new Error("useFiles must be used within a FilesProvider");
}
return context;
}
export { FilesProvider, useFiles };
@@ -6,8 +6,6 @@ import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
import { ConversationTrigger } from "#/api/open-hands.types";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
export const useCreateConversation = () => {
const navigate = useNavigate();
@@ -21,20 +19,16 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: {
conversation_trigger: ConversationTrigger;
q?: string;
selectedRepository?: GitRepository | null;
suggested_task?: SuggestedTask;
}) => {
if (variables.q) dispatch(setInitialPrompt(variables.q));
return OpenHands.createConversation(
variables.conversation_trigger,
variables.selectedRepository || undefined,
variables.q,
files,
replayJson || undefined,
variables.suggested_task || undefined,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config";
import { clearLastPage } from "#/utils/last-page";
export const useLogout = () => {
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
@@ -25,6 +26,9 @@ export const useLogout = () => {
setProviderTokensSet([]);
setProvidersAreSet(false);
// Clear the last page from localStorage
clearLastPage();
// Navigate to root page and refresh the page
navigate("/");
window.location.reload();
@@ -3,8 +3,17 @@ import { DEFAULT_SETTINGS } from "#/services/settings";
import OpenHands from "#/api/open-hands";
import { PostSettings, PostApiSettings } from "#/types/settings";
import { useSettings } from "../query/use-settings";
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
const saveSettingsMutationFn = async (
settings: Partial<PostSettings> | null,
) => {
// If settings is null, we're resetting
if (settings === null) {
await OpenHands.resetSettings();
return;
}
const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
const apiSettings: Partial<PostApiSettings> = {
llm_model: settings.LLM_MODEL,
llm_base_url: settings.LLM_BASE_URL,
@@ -29,14 +38,27 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
export const useSaveSettings = () => {
const queryClient = useQueryClient();
const { data: currentSettings } = useSettings();
const disableApiCalls = useDisableApiOnTos();
return useMutation({
mutationFn: async (settings: Partial<PostSettings>) => {
mutationFn: async (settings: Partial<PostSettings> | null) => {
// Skip API calls on TOS page
if (disableApiCalls) {
return;
}
if (settings === null) {
await saveSettingsMutationFn(null);
return;
}
const newSettings = { ...currentSettings, ...settings };
await saveSettingsMutationFn(newSettings);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
if (!disableApiCalls) {
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
},
meta: {
disableToast: true,
+5 -1
View File
@@ -1,14 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
export const useBalance = () => {
const { data: config } = useConfig();
const disableApiCalls = useDisableApiOnTos();
return useQuery({
queryKey: ["user", "balance"],
queryFn: OpenHands.getBalance,
enabled:
config?.APP_MODE === "saas" && config?.FEATURE_FLAGS.ENABLE_BILLING,
!disableApiCalls &&
config?.APP_MODE === "saas" &&
config?.FEATURE_FLAGS?.ENABLE_BILLING,
});
};
+8 -3
View File
@@ -1,10 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
export const useConfig = () =>
useQuery({
export const useConfig = () => {
const disableApiCalls = useDisableApiOnTos();
return useQuery({
queryKey: ["config"],
queryFn: OpenHands.getConfig,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
gcTime: 1000 * 60 * 15, // 15 minutes,
enabled: !disableApiCalls,
});
};
+3 -1
View File
@@ -3,17 +3,19 @@ import React from "react";
import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
const disableApiCalls = useDisableApiOnTos();
const appMode = React.useMemo(() => config?.APP_MODE, [config]);
return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode],
queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode,
enabled: !!appMode && !disableApiCalls,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
retry: false,
+16
View File
@@ -0,0 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { useConversation } from "#/context/conversation-context";
import { FileService } from "#/api/file-service/file-service.api";
interface UseListFileConfig {
path: string;
}
export const useListFile = (config: UseListFileConfig) => {
const { conversationId } = useConversation();
return useQuery({
queryKey: ["files", conversationId, config.path],
queryFn: () => FileService.getFile(conversationId, config.path),
enabled: false, // don't fetch by default, trigger manually via `refetch`
});
};
@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { useSelector } from "react-redux";
import { useConversation } from "#/context/conversation-context";
import { RootState } from "#/store";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { FileService } from "#/api/file-service/file-service.api";
interface UseListFilesConfig {
path?: string;
enabled?: boolean;
}
const DEFAULT_CONFIG: UseListFilesConfig = {
enabled: true,
};
export const useListFiles = (config: UseListFilesConfig = DEFAULT_CONFIG) => {
const { conversationId } = useConversation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
return useQuery({
queryKey: ["files", conversationId, config?.path],
queryFn: () => FileService.getFiles(conversationId, config?.path),
enabled: runtimeIsActive && !!config?.enabled,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};
+10 -6
View File
@@ -4,9 +4,9 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { Settings } from "#/types/settings";
import { useDisableApiOnTos } from "../use-disable-api-on-tos";
const getSettingsQueryFn = async (): Promise<Settings> => {
const getSettingsQueryFn = async () => {
const apiSettings = await OpenHands.getSettings();
return {
@@ -30,6 +30,7 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const disableApiCalls = useDisableApiOnTos();
const query = useQuery({
queryKey: ["settings", providerTokensSet],
@@ -40,6 +41,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !disableApiCalls,
meta: {
disableToast: true,
},
@@ -54,11 +56,13 @@ export const useSettings = () => {
React.useEffect(() => {
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = Object.keys(providers) as Array<
keyof typeof providers
>;
const setProviders = (
Object.keys(providers) as Array<keyof typeof providers>
).filter((key) => providers[key]);
setProviderTokensSet(setProviders);
const atLeastOneSet = setProviders.length > 0;
const atLeastOneSet = Object.values(query.data.PROVIDER_TOKENS_SET).some(
(value) => value,
);
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
+13 -1
View File
@@ -2,6 +2,7 @@ import React from "react";
import { generateAuthUrl } from "#/utils/generate-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types";
import { useAuth } from "#/context/auth-context";
import { useDisableApiOnTos } from "./use-disable-api-on-tos";
interface UseAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
@@ -10,8 +11,14 @@ interface UseAuthUrlConfig {
export const useAuthUrl = (config: UseAuthUrlConfig) => {
const { providersAreSet } = useAuth();
const disableApiCalls = useDisableApiOnTos();
return React.useMemo(() => {
// Disable auth URL generation on TOS page
if (disableApiCalls) {
return null;
}
if (config.appMode === "saas" && !providersAreSet) {
try {
return generateAuthUrl(
@@ -25,5 +32,10 @@ export const useAuthUrl = (config: UseAuthUrlConfig) => {
}
return null;
}, [providersAreSet, config.appMode, config.identityProvider]);
}, [
providersAreSet,
config.appMode,
config.identityProvider,
disableApiCalls,
]);
};
@@ -0,0 +1,26 @@
import { useMemo } from "react";
/**
* Custom hook that returns whether API calls should be disabled
* based on the current URL. Used to prevent infinite loops
* on the TOS page.
*
* This hook doesn't use useLocation to avoid Router context requirements,
* making it safe to use in components outside Router context.
*/
export const useDisableApiOnTos = (): boolean =>
// Memoize the result to prevent unnecessary re-renders
useMemo(() => {
// Get the current URL path from window.location
const currentPath = window.location.pathname;
const currentUrl = window.location.href;
// Check if the current path is the TOS page
const isTosPage = currentPath === "/tos";
// Also check if we're on an external TOS page that might be redirected
const isExternalTosPage =
currentPath.includes("/tos") || currentUrl.includes("all-hands.dev/tos");
return isTosPage || isExternalTosPage;
}, []);
+12 -1
View File
@@ -6,6 +6,7 @@ import {
setUrl,
} from "#/state/browser-slice";
import { clearSelectedRepository } from "#/state/initial-query-slice";
import { clearLastPage } from "#/utils/last-page";
export const useEndSession = () => {
const navigate = useNavigate();
@@ -13,14 +14,24 @@ export const useEndSession = () => {
/**
* End the current session by clearing the token and redirecting to the home page.
* @param {boolean} preserveLastPage - If true, don't clear the last page from localStorage
*/
const endSession = () => {
const endSession = (preserveLastPage = false) => {
dispatch(clearSelectedRepository());
// Reset browser state to initial values
dispatch(setUrl(browserInitialState.url));
dispatch(setScreenshotSrc(browserInitialState.screenshotSrc));
// Check if the current page is a conversation page
const isConversationPage = window.location.pathname.includes("/conversations/");
// Clear the last page from localStorage unless preserveLastPage is true
// and we're on a conversation page (to preserve the URL for after login)
if (!preserveLastPage) {
clearLastPage();
}
navigate("/");
};
+5 -1
View File
@@ -7,6 +7,7 @@ import { AgentState } from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { useEndSession } from "./use-end-session";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { saveLastPage } from "#/utils/last-page";
interface ServerError {
error: boolean | string;
@@ -32,8 +33,11 @@ export const useHandleWSEvents = () => {
if (isServerError(event)) {
if (event.error_code === 401) {
// Save the last page before ending session
saveLastPage();
displayErrorToast("Session expired.");
endSession();
// Pass true to preserve the last page we just saved
endSession(true);
return;
}
@@ -1,15 +1,22 @@
import React from "react";
import { handleCaptureConsent } from "#/utils/handle-capture-consent";
import { useSaveSettings } from "./mutation/use-save-settings";
import { useDisableApiOnTos } from "./use-disable-api-on-tos";
export const useMigrateUserConsent = () => {
const { mutate: saveUserSettings } = useSaveSettings();
const disableApiCalls = useDisableApiOnTos();
/**
* Migrate user consent to the settings store on the server.
*/
const migrateUserConsent = React.useCallback(
async (args?: { handleAnalyticsWasPresentInLocalStorage: () => void }) => {
// Skip migration on TOS page
if (disableApiCalls) {
return;
}
const userAnalyticsConsent = localStorage.getItem("analytics-consent");
if (userAnalyticsConsent) {
@@ -27,7 +34,7 @@ export const useMigrateUserConsent = () => {
localStorage.removeItem("analytics-consent");
}
},
[],
[disableApiCalls],
);
return { migrateUserConsent };
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { useNavigate, useLocation } from "react-router";
import { getLastPage, clearLastPage } from "../utils/last-page";
export const usePostLoginRedirect = (isLoggedIn: boolean) => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
if (isLoggedIn) {
// Check if there's a saved last page
const lastPage = getLastPage();
// Only redirect if:
// 1. There is a saved last page
// 2. The user is currently on the root page (/) or a generic page
// 3. The user is not already on the saved page
const isOnGenericPage = location.pathname === "/" ||
location.pathname === "/login" ||
location.pathname === "/tos";
if (lastPage && isOnGenericPage && location.pathname !== lastPage) {
navigate(lastPage);
}
// Always clear the last page after login, whether we redirected or not
clearLastPage();
}
}, [isLoggedIn, navigate, location.pathname]);
};

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