Compare commits

...

132 Commits

Author SHA1 Message Date
Engel Nyst
91a77aee2d Merge branch 'main' into gemini-fixes 2025-08-08 00:21:13 +02:00
Engel Nyst
73a7c7786d Load previous conversation by id (CLI) (#10156) 2025-08-07 23:09:20 +02:00
aeft
11d12c5a01 fix: prevent CLI argument parser defaults from overriding config file values (#10140) 2025-08-08 04:48:04 +08:00
Xingyao Wang
c4f303a07b chore(eval): Remove eval_infer_remote.sh script and related references (#10157)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-07 20:46:59 +00:00
Kenny Dizi
3a629cdf08 Add support model claude-opus-4-1-20250805 (#10120) 2025-08-07 18:48:34 +00:00
sp.wack
6ea33b657d chore(frontend): Remove some dead code (#10121) 2025-08-08 02:40:35 +08:00
Xingyao Wang
a526f53181 Add uvx CLI command to PR descriptions (#10142)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-08 01:51:55 +08:00
Xingyao Wang
0d28113df1 Fix Docker installation for swebench and mswebench images (#10124)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-07 23:42:35 +08:00
aeft
029a19ca05 fix: remove duplicate error message in provider validator (#10088) 2025-08-07 23:37:51 +08:00
Xingyao Wang
d525c5ad93 fix(config): support defining MCP servers via environment variables and improve logging (#10069)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-07 14:48:44 +00:00
chuckbutkus
881729b49c Fix user info api calls (#10137)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 23:57:52 -04:00
sp.wack
42ed36e5cc hotfix(frontend): Fix chat message font size (#10134) 2025-08-06 18:37:06 +00:00
Xingyao Wang
2b4e9137e3 chore(logging): Reduce microagents directory logging noise from WARNING to DEBUG (#10127)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 20:26:42 +02:00
greese-insight
37cebc1f8f fix: update git config to handle the necessary user name and email se… (#9975) 2025-08-06 20:25:26 +02:00
Graham Neubig
59ecf5515e Promote OpenHands LLM provider as the recommended option (#10108)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 13:33:12 -04:00
Rohit Malhotra
3f327a940f Paginate repo list from providers (#9826)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: Hiep Le <69354317+hieptl@users.noreply.github.com>
2025-08-06 13:03:46 -04:00
mamoodi
9c83a5623f Remove the "No secrets found" which is unnecessary (#10126)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 12:55:32 -04:00
Xingyao Wang
efa3c2187d Bump conversation history limit from 20 to 100 (#10128)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 16:43:31 +00:00
Jamesz12b
12bc965964 fix: Chat Width Limitation in Chat Window (#9895) 2025-08-06 16:11:56 +00:00
dependabot[bot]
256bad9f5a chore(deps-dev): bump eslint-plugin-prettier from 5.5.3 to 5.5.4 in /frontend in the eslint group (#10123)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 15:26:19 +00:00
Tim O'Farrell
e9700ecc3d Add "Session Timeout!" translation entry (#10122)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-06 15:00:01 +00:00
Graham Neubig
eba4294b08 Add Git credentials settings to frontend (#9956)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Abubakar <abubakaran102025@gmail.com>
2025-08-06 09:54:19 -04:00
Hiep Le
dbba60356e chore: remove the feature flag for the microagent management page. (#9874) 2025-08-06 17:46:05 +04:00
Hiep Le
dceff1fae4 feat(frontend): add a tooltip to repo dropdown on home page (#10079) 2025-08-06 17:16:18 +04:00
dependabot[bot]
5a35fa571a chore(deps): bump the version-all group in /frontend with 5 updates (#10084)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-06 17:12:55 +04:00
chuckbutkus
ff2cfb7bce Get auth URL from config if it is supplied. (#10111) 2025-08-06 08:58:08 -04:00
Graham Neubig
1c66347803 Improve stop button message for better user experience (#9860)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-05 21:53:40 -04:00
Engel Nyst
b71f258f34 Add Gemini API debugging tools and analysis
- test_gemini_api.py: Debug script to test Gemini 2.5 Pro thinking mode
  - Tests both direct API and proxy configurations
  - Patches litellm.completion to inspect all parameters sent to API
  - Analyzes response structure and token usage
  - Tests includeThoughts=True/False latency differences
  - Redacts API keys from debug output

- gemini_api_message_structure.md: Documents Gemini API message structure
  - Details thinking mode configuration and behavior
  - Shows token usage patterns (thinking vs regular tokens)
  - Explains latency variations with thinking enabled/disabled
  - Provides examples of API parameters and responses

Key findings:
- includeThoughts=False: ~8.9-15.8s latency, hides thinking content
- includeThoughts=True: ~13.5s latency, shows extensive thinking
- Thinking tokens vary significantly (661-1284) even for simple inputs
- Model performs thinking regardless of includeThoughts setting
2025-08-06 02:08:12 +02:00
Engel Nyst
e1c355c60f Implement direct kwargs approach for Gemini thinking config
- Use direct kwargs to pass generationConfig with thinkingConfig
- Comment out context manager approach (slower by 4+ seconds)
- Add comprehensive API parameter debugging test
- Confirmed all parameters sent to Gemini API including thinking config
- Performance: Direct kwargs ~12s vs Context manager ~16s
2025-08-06 01:46:34 +02:00
Engel Nyst
9d802fd7bd Move temperature parameter to generationConfig for Gemini thinking
- Place temperature=0 in generationConfig instead of top-level
- Remove top-level temperature to avoid parameter conflicts
- Maintain thinkingConfig functionality for internal reasoning
- Note: Higher latency (12-16s) is expected with thinking enabled
2025-08-06 01:20:43 +02:00
Engel Nyst
475ac9d29c Fix duplicate temperature parameter in Gemini thinking patch
- Remove duplicate temperature from generationConfig to avoid conflicts
- Keep top-level temperature parameter for proper API behavior
- Maintain thinkingConfig functionality for Gemini 2.5 Pro thinking
- Verified with real API calls showing clean parameter structure
2025-08-06 01:16:13 +02:00
Engel Nyst
9d6eb1c47e fix: apply pre-commit linting fixes
- Fix trailing whitespace and end-of-file issues
- Apply ruff formatting to all files
- Ensure code style consistency across the codebase
2025-08-06 00:42:49 +02:00
Engel Nyst
43d0385642 feat(llm): improve Gemini thinking patch with per-call isolation
- Refactor patch to apply/restore around each completion call instead of globally
- Use context manager pattern for automatic cleanup
- Prevent interference between different model instances
- Add comprehensive test coverage with pytest and unittest patterns
- Handle both sync and async transformation functions
- Robust error handling and logging
- No global state pollution - patch is isolated per call

This ensures the patch only affects Gemini models during their specific
completion calls and doesn't leak to other models or subsequent calls.
2025-08-06 00:38:31 +02:00
Engel Nyst
ada86336a2 feat(llm): integrate Gemini thinking patch directly into LLM class
- Add automatic Gemini thinking patch for gemini-2.5-pro models
- Patch is applied during LLM initialization for seamless integration
- No external monkey-patching required - works out of the box
- Adds thinkingConfig to enable visible thinking process in responses
- Includes proper error handling and debug logging
- Only applies to Gemini 2.5 Pro models (case-insensitive detection)

This enables Gemini's thinking capabilities automatically when using
OpenHands with gemini-2.5-pro models, improving response quality and
transparency without requiring any configuration changes.
2025-08-06 00:19:07 +02:00
Engel Nyst
8439626ada feat(llm): add OpenHands integration test for Gemini thinking patch
- Verifies patch works with actual OpenHands LLM module
- Tests sync litellm.completion() path used by OpenHands
- Confirms thinkingConfig is properly injected into requests
- Integration test passes, proving real-world compatibility

This test demonstrates that the monkey-patch approach successfully
integrates with OpenHands' LLM infrastructure without requiring
any changes to the core OpenHands codebase.
2025-08-06 00:14:38 +02:00
Engel Nyst
cf96ebe6f4 fix(llm): update gemini thinking patch to support both sync and async
- Added sync version patch for litellm.completion() (used by OpenHands)
- Updated test suite with dedicated sync and async test cases
- Enhanced practical example to demonstrate both sync and async usage
- Ensures complete compatibility with OpenHands LLM usage patterns

Key improvements:
- test_gemini_thinking_patch_sync: Tests sync litellm.completion() calls
- test_gemini_thinking_patch_async: Tests async litellm.acompletion() calls
- Updated example script shows both sync and async patching
- All 3 tests passing, confirming both code paths work correctly
2025-08-06 00:12:22 +02:00
Engel Nyst
764226967e feat(llm): complete gemini thinking patch with working test and example
- Successfully implemented monkey-patching approach for Gemini thinkingConfig
- Patch targets async_transform_request_body in vertex_and_google_ai_studio_gemini module
- Test verifies thinkingConfig is properly injected into generationConfig
- Added practical example script showing how to apply the patch
- Breakthrough: Found correct patching location after transformation function import analysis

Key findings:
- thinkingConfig filtering was happening in transformation.py GenerationConfig logic
- Patching at import location (vertex_and_google_ai_studio_gemini) works correctly
- Final payload includes: temperature, top_p, and thinkingConfig.includeThoughts=true
2025-08-06 00:01:08 +02:00
Engel Nyst
e4a1684197 test direct config 2025-08-05 23:54:21 +02:00
Engel Nyst
87644aa165 monkey patch test 2025-08-05 22:45:45 +02:00
Engel Nyst
255386bbfc gemini supports temp, top_p 2025-08-05 22:38:53 +02:00
Graham Neubig
238ae611f6 Fix: Add APIConnectionError to LLM_RETRY_EXCEPTIONS to handle temporary API errors (#9818)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-05 16:38:41 -04:00
Engel Nyst
5f5a58c9bd Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-08-05 21:38:10 +02:00
chuckbutkus
cda29107f1 Update user and group creation in Dockerfile (#10096) 2025-08-05 14:38:53 -04:00
chuckbutkus
97bfa96a15 Enterprise sso (#10008)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-08-04 17:50:59 -04:00
Xingyao Wang
0e2f2f4173 Add global git configuration to Dockerfile.j2 (#10080)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-05 04:10:09 +08:00
Rohit Malhotra
5554b7b418 refactor: modify ExperimentManager to take config instead of agent_config and call before session creation (#10001)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 16:05:05 -04:00
Chase Farmer
d30f77c60a Honor user-set flag for LOG_TO_FILE (#10078) 2025-08-04 19:20:20 +00:00
aeft
a36d1673fa feat(cli): add agent state validation to /resume command (#10066) 2025-08-04 21:14:21 +02:00
mamoodi
d233e89873 Fix Tavily search API key placeholder format (#10075)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 15:13:29 -04:00
Yumi Izumi
402b6224a6 feat: allow optional HTTP protocol for self-hosted GitLab instances (#9757)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-08-04 14:54:19 -04:00
aeft
4e5e2a7095 docs: fix typos and update section numbering in Development.md (#10067) 2025-08-04 14:50:00 -04:00
Tim O'Farrell
a0adbd741a Fix: Display logout option even when user is not available (#10077)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 11:24:20 -06:00
llamantino
d5cdecea21 fix(docker-runtime): adjust default port ranges to avoid Windows ephemeral ports (#9924) 2025-08-04 09:30:18 -04:00
Xingyao Wang
fef287fcb0 Always install Docker with MTU 1450 configuration (#10007)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-04 21:21:03 +08:00
Ryan H. Tran
6fc1a63eb8 [CLI] Add default fetch MCP server & update doc to require uvx (#9952) 2025-08-04 04:30:16 +00:00
Engel Nyst
cc64e7ba45 Merge branch 'main' into gemini-fixes 2025-08-04 00:16:03 +02:00
aeft
5364e2638b docs: update CodeAct agent step method Returns documentation (#10054) 2025-08-02 19:33:44 +00:00
Graham Neubig
d3983b00bd [Feature Request]: Make git username and email configurable (#9942)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-02 05:20:05 +08:00
Calvin Smith
39fff41dd4 Set default condenser to ConversationWindowCondenser (#10031)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-01 10:35:40 -06:00
Bashwara Undupitiya
d0a8c896c2 feature: Add Jira, Jira DC and Linear UI Integrations (#9761)
Co-authored-by: Wishmi Dhanapala <wishmis@verdentra.com>
2025-08-01 10:25:49 -05:00
dependabot[bot]
4f24bcaec9 chore(deps): bump the version-all group in /frontend with 7 updates (#10042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-01 15:23:19 +00:00
Tim O'Farrell
d3209f8c28 Add unit tests for LocalRuntime's vscode_url and web_hosts methods (#9984)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-01 07:02:28 -06:00
Rohit Malhotra
287c34b3f3 Add branch information to repository context to prevent unwanted branch switching (#9833) 2025-08-01 00:25:36 -04:00
Engel Nyst
3e1b841a99 Merge branch 'main' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-08-01 04:51:48 +02:00
Engel Nyst
54d3adc10f Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-08-01 04:51:31 +02:00
Rohit Malhotra
1cdc38eafb Add LLM disclaimer to Slack integration documentation (#10006)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 18:43:08 -04:00
Rohit Malhotra
ae045251f2 Revert "Add experiment for agent config" (#10032) 2025-07-31 21:25:44 +00:00
Tim O'Farrell
9b374cd6b8 Fix for issued due to changes in spec for custom secrets (#10028) 2025-07-31 14:49:56 -06:00
mamoodi
4759a78c12 Patch release 0.51.1 (#10023)
Co-authored-by: Xingyao Wang <xingyao@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 15:21:54 -04:00
greese-insight
d88e68eb49 fix: update openhands local runtime to handle provider tokens correctly (#9915)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-31 15:21:33 -04:00
Tim O'Farrell
b9abdf10ce Fixes for git diff viewer (#10026) 2025-07-31 15:19:56 -04:00
Denys Vitali
5b5a9718c2 fix(runtime): use async in git clone (#9334)
Co-authored-by: Tim O'Farrell <tofarr@gmail.com>
Co-authored-by: mamoodi <mamoodiha@gmail.com>
2025-07-31 13:59:20 -04:00
Robert Brennan
86dac5efe4 Improve connecting status messages with time expectations (#10016)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 15:20:33 +00:00
Ivan Dagelic
dfeab9f767 chore: env for installing third party providers (#9767)
Signed-off-by: Ivan Dagelic <dagelic.ivan@gmail.com>
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-07-31 15:20:06 +00:00
dependabot[bot]
4b13658401 chore(deps-dev): bump @tanstack/eslint-plugin-query from 5.81.2 to 5.83.1 in /frontend in the eslint group (#10019)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-31 15:14:13 +00:00
Carlos Freund
844b00a380 Make backend and frontend ports configurable in Makefile (#9722) 2025-07-31 11:11:43 -04:00
Carlos Freund
29fe911828 fix(conf): add cause to re-raised value-error to keep context. (#9940)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Xingyao Wang <xingyaoww@gmail.com>
2025-07-31 22:59:13 +08:00
Xingyao Wang
5282770a4c Fix litellm_proxy model info JSON parsing error handling (#10009)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 14:52:36 +00:00
Hiep Le
953902dcce feat(frontend): integrate with the updated get microagents API for the microagent management page. (#10010) 2025-07-31 18:42:07 +04:00
sp.wack
b28e0533e0 fix(feedback): Batch event feedback status request (#9884)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 18:07:06 +04:00
mamoodi
43555fa13b Release 0.51.0 (#9993) 2025-07-31 09:55:05 -04:00
Hiep Le
10ae481b91 refactor: improve the get microagents API (#9958)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-31 00:33:02 -04:00
Engel Nyst
a2be25b261 Merge branch 'main' into gemini-fixes 2025-07-31 05:43:04 +02:00
Xingyao Wang
c2e860fe92 Improve LLM call metadata (#10004)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 07:02:49 +08:00
Xingyao Wang
c2fc84e6ea Remove task completion status message from finish action display (#9977)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-31 04:33:45 +08:00
Xingyao Wang
6f44b7352e Add search API key settings to CLI (#9976)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-31 02:03:29 +08:00
dependabot[bot]
16106e6262 chore(deps): bump the version-all group in /frontend with 3 updates (#9997)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-30 15:20:33 +00:00
Xingyao Wang
6cea73b6da Add qwen-3-coder-480b to OpenHands provider (#9985)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-30 23:12:31 +08:00
llamantino
fdf9a49e28 feat(frontend): improve conversation card context menu (#9917) 2025-07-30 19:09:56 +04:00
Erkin Alp Güney
e348634dbd Fix user input commands being echoed twice in terminal (#9959)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-30 17:47:21 +04:00
Ryan H. Tran
b67db15f8a [CLI] Fix errno 21 warning when reading directory (#9990) 2025-07-30 21:38:45 +08:00
Engel Nyst
9e0731abfd feat(llm): add unit test to verify gemini thinking patch
Co-authored-by: Gemini <gemini@google.com>
2025-07-29 18:14:46 +02:00
Engel Nyst
b7109122da Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-07-29 16:43:40 +02:00
Engel Nyst
172e5d46a1 Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-07-29 16:38:10 +02:00
Engel Nyst
3e6768c742 Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-07-29 16:03:51 +02:00
Engel Nyst
cd95ce55bb feat(gemini): Add support for thinking parameter
- Refactors llm.py to correctly handle the 'thinking' parameter for Gemini models.
- Replaces 'reasoning_effort' with 'thinking' for specific Gemini models and reasoning effort levels.
- Adds 'allowed_openai_params' to ensure the 'thinking' parameter is passed through litellm's validation.
- Includes temporary test files (patch_litellm.py, test_patch.py) to document the validation process.

Co-authored-by: OpenHands-Gemini <gemini@openhands.ai>
2025-07-29 15:55:03 +02:00
Engel Nyst
c6ce36d205 Merge branch 'main' into gemini-fixes 2025-07-27 22:43:02 +02:00
Engel Nyst
1323733eee style: format files with pre-commit 2025-07-27 04:22:42 +02:00
Engel Nyst
efefe72b46 add last results 2025-07-27 04:15:35 +02:00
Engel Nyst
606d7750fa override litellm's high effort 2025-07-27 04:04:28 +02:00
Engel Nyst
336ddb030d fix finding gemini, args 2025-07-27 03:26:24 +02:00
Engel Nyst
1beb20746c Document comprehensive Gemini performance testing results
- Add complete performance benchmarking results for 16 configurations
- Document all test failures fixed with 100% success rate
- Include detailed configuration explanations and performance tiers
- Add LiteLLM internal mapping analysis showing reasoning_effort inefficiency
- Verify OpenHands automatic 128-token thinking budget optimization
- Document TODO for future testing with actual library integrations
- Provide clear recommendations based on performance data

Key findings:
- Thinking Budget 128 is optimal (5.7-6.4s performance)
- Direct API calls outperform LiteLLM proxy by 2-3x
- Reasoning effort modes are 3-4x slower than thinking budget
- OpenHands defaults to optimal configuration automatically

Co-authored-by: OpenHands <openhands@all-hands.dev>
2025-07-27 02:47:15 +02:00
Engel Nyst
dc99292418 Fix all remaining test failures for Gemini API compatibility
- Fix thinking budget configuration to use types.ThinkingConfig()
- Fix Part API syntax for function calls and responses
- Add JSON argument parsing for New API compatibility
- Fix tools configuration to be passed in config object
- Add streaming response support in extract_tool_call function
- All 16 test configurations now pass with 100% success rate

Performance results show Thinking Budget 128 provides optimal
balance of speed (5.7-6.4s) vs capabilities.

Co-authored-by: OpenHands <openhands@all-hands.dev>
2025-07-27 02:31:09 +02:00
Engel Nyst
9445165a23 Fix LiteLLM proxy model name for Gemini tests
Use litellm_proxy/gemini/gemini-2.5-pro instead of gemini-2.5-pro
for all proxy-based test configurations to resolve VertexAIException
'Not Found' errors. This enables comprehensive testing of reasoning
effort and thinking budget parameters through the LiteLLM proxy.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-27 01:39:35 +02:00
Engel Nyst
0c254f9376 Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-07-27 01:23:20 +02:00
Engel Nyst
1dbcc527e7 Fix Gemini reasoning effort handling
- Change default reasoning_effort to None globally in llm_config.py
- Set reasoning_effort='high' for non-Gemini models in model_post_init
- Update llm.py to only map 'low' and None to thinking budget (128 tokens)
- Let 'medium' and 'high' pass through to API as reasoning_effort
- Verified 5.6x performance difference between optimized (1.2s) and full reasoning (7s)

This allows users to choose between:
- Fast optimized thinking budget (low/default): ~1-2s
- Full reasoning effort (medium/high): ~6-7s
2025-07-27 00:15:42 +02:00
Engel Nyst
209dc72ac5 Merge branch 'gemini-fixes' of https://github.com/All-Hands-AI/OpenHands into gemini-fixes 2025-07-26 23:44:24 +02:00
Engel Nyst
b258c7a3b2 Fix remaining LITELLM_API_KEY references
🔧 CREDENTIAL FIX: Updated documentation references
- test_utils.py: Fixed help message to use LITELLM_PROXY_API_KEY
- test_litellm_comprehensive.py: Fixed documentation strings

 ALL REFERENCES UPDATED: Now consistently using LITELLM_PROXY_API_KEY throughout codebase
2025-07-26 23:41:09 +02:00
Engel Nyst
70968e8953 Complete tool call architecture implementation
 ARCHITECTURE REDESIGNED: Implemented comprehensive 3-step tool call testing

🔧 NEW WORKFLOW:
- Step 1: Ask LLM to calculate 45×126 using math tool
- Step 2: Execute tool (returns 5670) and send result back to LLM
- Step 3: Ask LLM to summarize the conversation

📁 FILES CREATED:
- performance_with_tools.md: Comprehensive documentation
- test_utils.py: Shared utilities with standardized math tool

🔄 FILES UPDATED:
- test_thinking_budget.py: New tool call architecture
- test_litellm_comprehensive.py: New tool call architecture
- test_native_gemini.py: New tool call architecture
- test_openhands_gemini_fix.py: New tool call architecture
- run_performance_tests.py: Updated orchestrator for new architecture

🔐 SECURITY ENHANCED:
- All tests now use environment variables only
- Fixed LITELLM_PROXY_API_KEY credential handling
- No API keys hardcoded in any files
- Proper error handling when credentials missing

📊 TESTING IMPROVEMENTS:
- Realistic tool call workflows instead of simple prompts
- Message preservation across all conversation steps
- Performance metrics for each step + total duration
- Tool accuracy validation (expects 5670 result)
- Centralized tool call testing logic

🎯 READY FOR: Comprehensive performance testing with realistic tool usage patterns
2025-07-26 23:37:21 +02:00
Engel Nyst
a199e1d8a2 Delete PERFORMANCE_TESTING.md 2025-07-26 22:04:02 +02:00
Engel Nyst
e6d8283b6c 🧹 CONSOLIDATE: Deduplicate and organize test suite
- Remove redundant test files: quick_test.py, test_litellm_performance.py, test_openhands_litellm.py
- Create test_litellm_comprehensive.py consolidating all LiteLLM testing
- Update run_performance_tests.py for new test structure
- Keep test_thinking_budget.py as primary thinking/reasoning test per user preference
- Add TEST_SUITE_SUMMARY.md documenting consolidated structure
- Maintain complete test coverage while eliminating redundancy

Test Organization:
├── test_thinking_budget.py (Primary thinking/reasoning)
├── test_litellm_comprehensive.py (All LiteLLM scenarios)
├── test_native_gemini.py (Baseline performance)
├── test_openhands_gemini_fix.py (Fix verification)
└── run_performance_tests.py (Orchestrator)

Co-authored-by: OpenHands Agent <openhands@all-hands.dev>
2025-07-26 20:59:13 +02:00
Engel Nyst
23b0e96c67 Fix configuration precedence in CLI mode 2025-07-26 20:50:36 +02:00
Engel Nyst
f6b04b6f51 📊 DOCUMENT: OpenHands Gemini performance fix implementation
- Added implementation section with code details and test results
- Documented 2.5x speedup achievement: ~25s → ~10s
- Verified consistent performance: 10.432s and 9.309s
- Updated next steps with remaining 2x gap investigation

Co-authored-by: OpenHands Agent <openhands@all-hands.dev>
2025-07-26 20:03:32 +02:00
Engel Nyst
25ca337c7e 🚀 IMPLEMENT: Gemini performance fix with 128-token thinking budget
- Replace reasoning_effort with thinking={"budget_tokens": 128} for Gemini models
- Achieves 2.4x speedup: ~25s → ~10s response time
- Based on comprehensive performance investigation findings
- Add test_openhands_gemini_fix.py to verify improvement
- Tested: 10.432s and 9.309s (consistent excellent performance)

Co-authored-by: OpenHands Agent <openhands@all-hands.dev>
2025-07-26 20:03:03 +02:00
Engel Nyst
859a43d5f4 🎯 FINAL BREAKTHROUGH: LiteLLM comprehensive testing results
- Confirmed 128-token thinking budget is optimal (11.366s vs 27.296s)
- Discovered LiteLLM reasoning_effort mapping to suboptimal budgets
- LiteLLM with thinking=128 tokens matches native API performance
- Reduced performance gap from 5x to 2x vs Gemini CLI target
- Added comprehensive test results and implementation recommendations

Co-authored-by: OpenHands Agent <openhands@all-hands.dev>
2025-07-26 19:54:51 +02:00
Engel Nyst
a679468e1c reduce logspam 2025-07-26 19:44:47 +02:00
Engel Nyst
5de44642eb test litellm 2025-07-26 19:39:53 +02:00
Engel Nyst
03dea2d689 📊 MAJOR PROGRESS: Updated investigation with API breakthrough
- BREAKTHROUGH: New google.genai API + thinking_budget=128 achieves 9.6s
- PROGRESS: Reduced performance gap from 5x to 2x vs Gemini CLI
- VALIDATED: API version and thinking budget are primary factors
- REMAINING: 2x gap to close with streaming, headers, two-phase approach

Performance Summary:
- Gemini CLI: ~5s (target)
- New API optimized: 9.6s  (major improvement)
- Old API default: ~28s 🐌 (baseline)

Co-authored-by: Gemini
2025-07-26 18:41:01 +02:00
Engel Nyst
280285c0f9 🔧 FIXED: Updated thinking budget tests to use new google.genai API
- FIXED: Use google.genai with types.ThinkingConfig instead of old API
- BREAKTHROUGH: New API with thinking_budget=128 achieves 9.6s (3x faster!)
- VALIDATED: Gemini CLI configuration (128 tokens) is optimal setting
- PROGRESS: Reduced gap from 5x to 2x vs Gemini CLI performance

Performance Results:
- New API + thinking_budget=128: 9.622s  (BEST)
- Old API default: 27.993s 🐌 (3x slower)
- Speed improvement: 3.04x faster with correct API + config

Co-authored-by: Gemini
2025-07-26 18:40:24 +02:00
Engel Nyst
8823566b51 🔧 CORRECTED: Fixed performance analysis error
- CORRECTED: Gemini CLI is ~5s (FAST), not ~21s as mistakenly stated
- CLARIFIED: HTTP requests (972ms + 3714ms = ~4.7s) match fast total time
- IDENTIFIED: Root cause is configuration differences, not endpoint differences
- CLEAR NEXT STEPS: Implement streaming, SDK headers, thinking budget in OpenHands
- TARGET: Reduce OpenHands performance from ~25s to ~5s (5x improvement)

Co-authored-by: Gemini
2025-07-26 03:53:56 +02:00
Engel Nyst
abd199ef9e 🚀 BREAKTHROUGH: Captured actual Gemini API requests from CLI
- Successfully resolved bundle/logging issue by running npm run bundle
- Captured full HTTP request details showing actual API configuration
- CORRECTED: play.googleapis.com was telemetry, not API endpoint
- DISCOVERED: Gemini CLI uses same generativelanguage.googleapis.com as our implementations
- IDENTIFIED: Key differences in streaming, headers, thinking budget, two-phase approach
- PERFORMANCE: Individual requests fast (972ms + 3714ms) but total still ~21s
- NEXT: Test streaming vs non-streaming and SDK headers in our implementations

Co-authored-by: Gemini
2025-07-26 03:47:50 +02:00
Engel Nyst
28cdb80967 🔍 CRITICAL DISCOVERY: Gemini CLI uses play.googleapis.com endpoint
- Gemini CLI hits play.googleapis.com (fast: 2.6-5.2s)
- Our implementations hit generativelanguage.googleapis.com (slow: ~25s)
- Same @google/genai SDK routes to different endpoints
- 5-10x performance difference explained by endpoint choice
- Next: investigate what configuration triggers play.googleapis.com

Co-authored-by: Claude-3.5-Sonnet
2025-07-26 03:10:25 +02:00
Engel Nyst
3ceb0646ee 🚨 BREAKTHROUGH: Gemini CLI achieves 2.6-5.2s with gemini-2.5-pro
Major discovery validates user's fast performance reports:
- Google's official Gemini CLI: 2.6-5.2 seconds with gemini-2.5-pro
- Our test implementations: ~25 seconds (5-10x slower!)
- Uses native @google/genai SDK with built-in debug mode
- Confirms fast performance is achievable, identifies optimization gap

Test results:
- Simple greeting: 2.589s
- Code generation: 5.188s

Next: Analyze Gemini CLI's configuration vs our implementations

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-26 02:50:40 +02:00
Engel Nyst
c87c14a5a2 Add direct code analysis experiment to investigation
- Plan to modify RooCode extension to log API requests
- Update status with confirmed findings and critical mystery
- Focus on capturing exact request payload to LiteLLM proxy
- Remove outdated next steps, keep all test results

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-26 02:38:38 +02:00
Engel Nyst
d7907ee9a9 Update investigation: User reports RooCode fast with gemini-2.5-pro
- Discovered thinking budget configuration in RooCode
- Tested thinking budget impact: small improvement (2-3s)
- All our tests show ~25s with gemini-2.5-pro regardless of method
- Need to verify user's claim and find missing optimizations
- LiteLLM overhead confirmed minimal (1-3s)

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-25 23:37:28 +02:00
Engel Nyst
e26e4cf09d all tests completed with 25 secs 2025-07-25 23:27:22 +02:00
Engel Nyst
8881f0b8af change env var 2025-07-25 22:36:15 +02:00
Engel Nyst
d20b3606b4 Add systematic performance testing suite for Gemini investigation
- Created comprehensive test suite to isolate root cause of performance issues
- test_litellm_performance.py: Tests pure LiteLLM with different configurations
- test_openhands_litellm.py: Tests LiteLLM exactly as OpenHands calls it
- test_native_gemini.py: Tests native Google Generative AI (baseline)
- run_performance_tests.py: Automated test runner with comparative analysis
- PERFORMANCE_TESTING.md: Documentation and usage guide

This systematic approach will help determine if the issue is:
1. LiteLLM abstraction overhead
2. OpenHands-specific configuration problems
3. Missing streaming optimization
4. Specific parameter issues

Ready to run tests and identify the exact cause before implementing fixes.

Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-25 22:30:15 +02:00
Engel Nyst
769773ad95 Add comprehensive Gemini performance investigation
- Analyzed RooCode's native @google/genai implementation
- Analyzed OpenHands' LiteLLM-based implementation
- Identified key performance differences:
  * RooCode uses native Google SDK vs OpenHands uses LiteLLM wrapper
  * RooCode always streams vs OpenHands may not stream
  * RooCode uses native Gemini format vs OpenHands uses OpenAI format
  * RooCode has native reasoning support vs OpenHands has limited support
- Proposed solutions: Native Gemini provider or LiteLLM optimizations
- Ready for implementation and performance testing
2025-07-25 22:20:16 +02:00
Engel Nyst
5d308b6df3 Fix formatting issues found by pre-commit hooks
Applied automatic formatting fixes from ruff and ruff-format to ensure
code style consistency.

Co-authored-by: OpenHands-Claude <openhands@all-hands.dev>
2025-07-25 22:06:59 +02:00
Engel Nyst
4e0a985f34 add tests 2025-07-25 21:42:03 +02:00
Engel Nyst
c6e728f76b The configuration precedence is now in the correct order
When the user has a config.toml with an LLM configuration, it will be used unless overridden by the -l parameter
The -l parameter now correctly finds and prioritizes the specified LLM configuration
regardless of what settings.json says
2025-07-25 21:28:31 +02:00
247 changed files with 14392 additions and 3658 deletions

49
.github/scripts/update_pr_description.sh vendored Executable file
View File

@@ -0,0 +1,49 @@
#!/bin/bash
# This script updates the PR description with commands to run the PR locally
# It adds both Docker and uvx commands
# Get the branch name for the PR
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
# Define the Docker command
DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
--name openhands-app-$SHORT_SHA \
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
# Define the uvx command
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@$BRANCH_NAME openhands"
# Get the current PR body
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
# Prepare the new PR body with both commands
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
# For existing PR descriptions, replace the command section
NEW_PR_BODY=$(echo "$PR_BODY" | sed "s|To run this PR locally, use the following command:.*\`\`\`|To run this PR locally, use the following command:\n\nGUI with Docker:\n\`\`\`\n$DOCKER_RUN_COMMAND\n\`\`\`\n\nCLI with uvx:\n\`\`\`\n$UVX_RUN_COMMAND\n\`\`\`|s")
else
# For new PR descriptions
NEW_PR_BODY="${PR_BODY}
---
To run this PR locally, use the following command:
GUI with Docker:
\`\`\`
$DOCKER_RUN_COMMAND
\`\`\`
CLI with uvx:
\`\`\`
$UVX_RUN_COMMAND
\`\`\`"
fi
# Update the PR description
echo "Updating PR description with Docker and uvx commands"
gh pr edit $PR_NUMBER --body "$NEW_PR_BODY"

View File

@@ -332,29 +332,5 @@ jobs:
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
shell: bash
run: |
echo "updating PR description"
DOCKER_RUN_COMMAND="docker run -it --rm \
-p 3000:3000 \
-v /var/run/docker.sock:/var/run/docker.sock \
--add-host host.docker.internal:host-gateway \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:$SHORT_SHA-nikolaik \
--name openhands-app-$SHORT_SHA \
docker.all-hands.dev/all-hands-ai/openhands:$SHORT_SHA"
PR_BODY=$(gh pr view $PR_NUMBER --json body --jq .body)
if echo "$PR_BODY" | grep -q "To run this PR locally, use the following command:"; then
UPDATED_PR_BODY=$(echo "${PR_BODY}" | sed -E "s|docker run -it --rm.*|$DOCKER_RUN_COMMAND|")
else
UPDATED_PR_BODY="${PR_BODY}
---
To run this PR locally, use the following command:
\`\`\`
$DOCKER_RUN_COMMAND
\`\`\`"
fi
echo "updated body: $UPDATED_PR_BODY"
gh pr edit $PR_NUMBER --body "$UPDATED_PR_BODY"
echo "Updating PR description with Docker and uvx commands"
bash ${GITHUB_WORKSPACE}/.github/scripts/update_pr_description.sh

View File

@@ -34,7 +34,7 @@ _Dev Container: Reopen in Container_ command from the Command Palette
#### Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJs`, you can use
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
```bash
@@ -71,7 +71,7 @@ This command will prompt you to enter the LLM API key, model name, and other var
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Note: If you have previously run OpenHands using the docker command, you may have already set some environmental
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
@@ -154,12 +154,12 @@ poetry run pytest ./tests/unit/test_*.py
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
### 9. Use existing Docker image
### 10. Use existing Docker image
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.50-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.51-nikolaik`
## Develop inside Docker container

View File

@@ -3,10 +3,10 @@ SHELL=/usr/bin/env bash
# Variables
BACKEND_HOST ?= "127.0.0.1"
BACKEND_PORT = 3000
BACKEND_PORT ?= 3000
BACKEND_HOST_PORT = "$(BACKEND_HOST):$(BACKEND_PORT)"
FRONTEND_HOST ?= "127.0.0.1"
FRONTEND_PORT = 3001
FRONTEND_PORT ?= 3001
DEFAULT_WORKSPACE_DIR = "./workspace"
DEFAULT_MODEL = "gpt-4o"
CONFIG_FILE = config.toml

View File

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

View File

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

View File

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

126
TEST_SUITE_SUMMARY.md Normal file
View File

@@ -0,0 +1,126 @@
# Consolidated Gemini Performance Test Suite
This document describes the consolidated and deduplicated test suite for investigating Gemini 2.5 Pro performance issues in OpenHands.
## 📁 Test Files Overview
### 1. `test_thinking_budget.py` - **PRIMARY THINKING/REASONING TEST**
**Purpose**: Primary test for thinking budget and reasoning effort configurations
**Features**:
- Tests old vs new Google Generative AI APIs
- Compares thinking budget configurations (128, 1024, 2048, 4096 tokens)
- Tests reasoning_effort parameters via LiteLLM
- Includes direct REST API calls for comparison
- **User Preference**: This is the main file for thinking/reasoning tests
### 2. `test_litellm_comprehensive.py` - **COMPREHENSIVE LITELLM TEST**
**Purpose**: Consolidated LiteLLM performance testing (replaces test_litellm_performance.py + test_openhands_litellm.py)
**Features**:
- Basic LiteLLM configurations (streaming, temperature, etc.)
- OpenHands-style configuration and calls
- Reasoning effort and thinking budget parameters
- Comprehensive performance analysis and comparison
- **Consolidation**: Combines functionality from 2 previous files
### 3. `test_native_gemini.py` - **NATIVE GOOGLE API TEST**
**Purpose**: Tests native Google Generative AI library (like RooCode uses)
**Features**:
- Direct Google API calls without LiteLLM abstraction
- Streaming and non-streaming tests
- Performance comparison baseline
- **Baseline**: Shows optimal performance without middleware
### 4. `test_openhands_gemini_fix.py` - **OPENHANDS FIX VERIFICATION**
**Purpose**: Tests the actual OpenHands Gemini performance fix implementation
**Features**:
- Tests OpenHands with optimized thinking budget configuration
- Verifies 2.5x speedup (from ~25s to ~10s)
- Configuration inspection and validation
- **Implementation**: Tests the actual fix we deployed
### 5. `run_performance_tests.py` - **TEST ORCHESTRATOR**
**Purpose**: Runs all tests in sequence and provides comprehensive analysis
**Features**:
- Dependency checking
- Sequential test execution
- Performance metrics extraction
- Comparative analysis across all test types
- **Orchestrator**: Runs all tests and provides summary
## 🗑️ Removed Files (Redundant)
### Removed: `quick_test.py`
- **Reason**: Very basic test, functionality covered by `test_native_gemini.py`
- **Redundancy**: Simple native API test already in comprehensive native test
### Removed: `test_litellm_performance.py`
- **Reason**: Merged into `test_litellm_comprehensive.py`
- **Redundancy**: Basic LiteLLM configurations now in comprehensive test
### Removed: `test_openhands_litellm.py`
- **Reason**: Merged into `test_litellm_comprehensive.py`
- **Redundancy**: OpenHands-style calls now in comprehensive test
## 🎯 Test Suite Organization
```
Performance Testing Hierarchy:
├── run_performance_tests.py (Orchestrator)
├── test_thinking_budget.py (Primary thinking/reasoning)
├── test_litellm_comprehensive.py (All LiteLLM scenarios)
├── test_native_gemini.py (Baseline performance)
└── test_openhands_gemini_fix.py (Fix verification)
```
## 🚀 Usage
### Run Individual Tests:
```bash
# Primary thinking/reasoning test
python test_thinking_budget.py
# Comprehensive LiteLLM test
python test_litellm_comprehensive.py
# Native API baseline
python test_native_gemini.py
# OpenHands fix verification
python test_openhands_gemini_fix.py
```
### Run Complete Suite:
```bash
# Run all tests with analysis
python run_performance_tests.py
```
## 📊 Test Coverage
| Test Aspect | Primary Test File | Coverage |
|-------------|------------------|----------|
| **Thinking Budget** | `test_thinking_budget.py` | ✅ Complete |
| **Reasoning Effort** | `test_thinking_budget.py` | ✅ Complete |
| **LiteLLM Performance** | `test_litellm_comprehensive.py` | ✅ Complete |
| **OpenHands Style** | `test_litellm_comprehensive.py` | ✅ Complete |
| **Native API Baseline** | `test_native_gemini.py` | ✅ Complete |
| **Fix Verification** | `test_openhands_gemini_fix.py` | ✅ Complete |
| **Streaming vs Non-streaming** | All files | ✅ Complete |
| **Parameter Variations** | All files | ✅ Complete |
## 🎉 Benefits of Consolidation
1. **Reduced Redundancy**: Eliminated duplicate test logic across 3 files
2. **Better Organization**: Clear separation of concerns by test purpose
3. **Easier Maintenance**: Single comprehensive test instead of multiple overlapping ones
4. **User Preference**: `test_thinking_budget.py` as primary thinking/reasoning test
5. **Complete Coverage**: All original functionality preserved and enhanced
## 🔧 Dependencies
- `litellm` - For LiteLLM testing
- `google-generativeai` - For old Google API
- `google-genai` - For new Google API with thinking budget
- `openhands` - For OpenHands fix testing
All dependencies are checked by `run_performance_tests.py` before execution.

View File

@@ -0,0 +1,752 @@
{
"test_suite": "comprehensive_performance_analysis",
"timestamp": 1753576041.7115579,
"total_tests": 16,
"successful_tests": 16,
"thinking_budget_tests": {
"test_type": "thinking_budget",
"timestamp": 1753575753.837211,
"total_configs": 7,
"successful_configs": 7,
"results": [
{
"success": true,
"error": null,
"step1_duration": 2.797980308532715,
"step2_duration": 1.8835067749023438e-05,
"step3_duration": 2.499279260635376,
"total_duration": 5.2979230880737305,
"tool_call_success": true,
"tool_call_result": "5670.0",
"result_correct": false,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 86,
"message_count": 6,
"config_name": "Old API (No Thinking)",
"timestamp": 1753575680.1571221
},
{
"success": true,
"error": null,
"step1_duration": 1.8824458122253418,
"step2_duration": 1.5384819507598877,
"step3_duration": 2.318272113800049,
"total_duration": 5.739390850067139,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 5,
"step3_response_length": 160,
"message_count": 6,
"config_name": "New API - Thinking Budget: 128",
"timestamp": 1753575685.896559
},
{
"success": true,
"error": null,
"step1_duration": 2.7450361251831055,
"step2_duration": 1.0403151512145996,
"step3_duration": 5.529464960098267,
"total_duration": 9.314986944198608,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 36,
"step3_response_length": 153,
"message_count": 6,
"config_name": "New API - Thinking Budget: 1024",
"timestamp": 1753575695.211576
},
{
"success": true,
"error": null,
"step1_duration": 3.2801640033721924,
"step2_duration": 1.226274013519287,
"step3_duration": 5.528562068939209,
"total_duration": 10.035185813903809,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 7,
"step3_response_length": 131,
"message_count": 6,
"config_name": "New API - Thinking Budget: 4096",
"timestamp": 1753575705.246801
},
{
"success": true,
"error": null,
"step1_duration": 4.210190773010254,
"step2_duration": 7.360184669494629,
"step3_duration": 9.522583961486816,
"total_duration": 21.093040704727173,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 283,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: Low",
"timestamp": 1753575726.339884
},
{
"success": true,
"error": null,
"step1_duration": 3.9966609477996826,
"step2_duration": 1.2283189296722412,
"step3_duration": 15.889936923980713,
"total_duration": 21.115014791488647,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 0,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: High",
"timestamp": 1753575747.454922
},
{
"success": true,
"error": null,
"step1_duration": 2.030133008956909,
"step2_duration": 1.9902338981628418,
"step3_duration": 2.3604180812835693,
"total_duration": 6.380887031555176,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 277,
"message_count": 6,
"config_name": "LiteLLM - Thinking Budget: 128",
"timestamp": 1753575753.83583
}
]
},
"litellm_comprehensive_tests": {
"test_type": "litellm_comprehensive",
"timestamp": 1753575966.9497,
"total_configs": 9,
"successful_configs": 9,
"results": [
{
"success": true,
"error": null,
"step1_duration": 3.1620140075683594,
"step2_duration": 6.163906097412109,
"step3_duration": 8.57595705986023,
"total_duration": 17.901986122131348,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 290,
"message_count": 6,
"config_name": "Basic LiteLLM",
"timestamp": 1753575823.836127
},
{
"success": true,
"error": null,
"step1_duration": 2.643059253692627,
"step2_duration": 4.244822978973389,
"step3_duration": 8.579889059066772,
"total_duration": 15.474514722824097,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 0,
"message_count": 6,
"config_name": "LiteLLM with Streaming",
"timestamp": 1753575839.3106902
},
{
"success": true,
"error": null,
"step1_duration": 3.299806833267212,
"step2_duration": 4.562235116958618,
"step3_duration": 9.42275094985962,
"total_duration": 17.284837007522583,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 288,
"message_count": 6,
"config_name": "OpenHands Style (No Stream)",
"timestamp": 1753575856.595548
},
{
"success": true,
"error": null,
"step1_duration": 2.8680617809295654,
"step2_duration": 4.986494064331055,
"step3_duration": 11.908216714859009,
"total_duration": 19.762842893600464,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 303,
"message_count": 6,
"config_name": "OpenHands Style (Streaming)",
"timestamp": 1753575876.358408
},
{
"success": true,
"error": null,
"step1_duration": 4.153742074966431,
"step2_duration": 1.2760770320892334,
"step3_duration": 10.748784065246582,
"total_duration": 16.178749799728394,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 0,
"message_count": 6,
"config_name": "Reasoning Effort: Low",
"timestamp": 1753575892.5371861
},
{
"success": true,
"error": null,
"step1_duration": 4.199495792388916,
"step2_duration": 11.224999904632568,
"step3_duration": 6.673478841781616,
"total_duration": 22.098058938980103,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 280,
"message_count": 6,
"config_name": "Reasoning Effort: Medium",
"timestamp": 1753575914.6352708
},
{
"success": true,
"error": null,
"step1_duration": 3.7451419830322266,
"step2_duration": 1.131227970123291,
"step3_duration": 12.550342082977295,
"total_duration": 17.426751136779785,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 306,
"message_count": 6,
"config_name": "Reasoning Effort: High",
"timestamp": 1753575932.0620391
},
{
"success": true,
"error": null,
"step1_duration": 3.0755691528320312,
"step2_duration": 3.7900118827819824,
"step3_duration": 8.599286079406738,
"total_duration": 15.464945077896118,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 306,
"message_count": 6,
"config_name": "Thinking Budget: 128",
"timestamp": 1753575947.527002
},
{
"success": true,
"error": null,
"step1_duration": 2.970345973968506,
"step2_duration": 4.713220119476318,
"step3_duration": 11.738292932510376,
"total_duration": 19.421957969665527,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 310,
"message_count": 6,
"config_name": "Thinking Budget: 1024",
"timestamp": 1753575966.948982
}
]
},
"summary": {
"all_results": [
{
"success": true,
"error": null,
"step1_duration": 2.797980308532715,
"step2_duration": 1.8835067749023438e-05,
"step3_duration": 2.499279260635376,
"total_duration": 5.2979230880737305,
"tool_call_success": true,
"tool_call_result": "5670.0",
"result_correct": false,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 86,
"message_count": 6,
"config_name": "Old API (No Thinking)",
"timestamp": 1753575680.1571221
},
{
"success": true,
"error": null,
"step1_duration": 1.8824458122253418,
"step2_duration": 1.5384819507598877,
"step3_duration": 2.318272113800049,
"total_duration": 5.739390850067139,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 5,
"step3_response_length": 160,
"message_count": 6,
"config_name": "New API - Thinking Budget: 128",
"timestamp": 1753575685.896559
},
{
"success": true,
"error": null,
"step1_duration": 2.7450361251831055,
"step2_duration": 1.0403151512145996,
"step3_duration": 5.529464960098267,
"total_duration": 9.314986944198608,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 36,
"step3_response_length": 153,
"message_count": 6,
"config_name": "New API - Thinking Budget: 1024",
"timestamp": 1753575695.211576
},
{
"success": true,
"error": null,
"step1_duration": 3.2801640033721924,
"step2_duration": 1.226274013519287,
"step3_duration": 5.528562068939209,
"total_duration": 10.035185813903809,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 7,
"step3_response_length": 131,
"message_count": 6,
"config_name": "New API - Thinking Budget: 4096",
"timestamp": 1753575705.246801
},
{
"success": true,
"error": null,
"step1_duration": 4.210190773010254,
"step2_duration": 7.360184669494629,
"step3_duration": 9.522583961486816,
"total_duration": 21.093040704727173,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 283,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: Low",
"timestamp": 1753575726.339884
},
{
"success": true,
"error": null,
"step1_duration": 3.9966609477996826,
"step2_duration": 1.2283189296722412,
"step3_duration": 15.889936923980713,
"total_duration": 21.115014791488647,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 0,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: High",
"timestamp": 1753575747.454922
},
{
"success": true,
"error": null,
"step1_duration": 2.030133008956909,
"step2_duration": 1.9902338981628418,
"step3_duration": 2.3604180812835693,
"total_duration": 6.380887031555176,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 277,
"message_count": 6,
"config_name": "LiteLLM - Thinking Budget: 128",
"timestamp": 1753575753.83583
},
{
"success": true,
"error": null,
"step1_duration": 3.1620140075683594,
"step2_duration": 6.163906097412109,
"step3_duration": 8.57595705986023,
"total_duration": 17.901986122131348,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 290,
"message_count": 6,
"config_name": "Basic LiteLLM",
"timestamp": 1753575823.836127
},
{
"success": true,
"error": null,
"step1_duration": 2.643059253692627,
"step2_duration": 4.244822978973389,
"step3_duration": 8.579889059066772,
"total_duration": 15.474514722824097,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 0,
"message_count": 6,
"config_name": "LiteLLM with Streaming",
"timestamp": 1753575839.3106902
},
{
"success": true,
"error": null,
"step1_duration": 3.299806833267212,
"step2_duration": 4.562235116958618,
"step3_duration": 9.42275094985962,
"total_duration": 17.284837007522583,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 288,
"message_count": 6,
"config_name": "OpenHands Style (No Stream)",
"timestamp": 1753575856.595548
},
{
"success": true,
"error": null,
"step1_duration": 2.8680617809295654,
"step2_duration": 4.986494064331055,
"step3_duration": 11.908216714859009,
"total_duration": 19.762842893600464,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 303,
"message_count": 6,
"config_name": "OpenHands Style (Streaming)",
"timestamp": 1753575876.358408
},
{
"success": true,
"error": null,
"step1_duration": 4.153742074966431,
"step2_duration": 1.2760770320892334,
"step3_duration": 10.748784065246582,
"total_duration": 16.178749799728394,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 0,
"message_count": 6,
"config_name": "Reasoning Effort: Low",
"timestamp": 1753575892.5371861
},
{
"success": true,
"error": null,
"step1_duration": 4.199495792388916,
"step2_duration": 11.224999904632568,
"step3_duration": 6.673478841781616,
"total_duration": 22.098058938980103,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 280,
"message_count": 6,
"config_name": "Reasoning Effort: Medium",
"timestamp": 1753575914.6352708
},
{
"success": true,
"error": null,
"step1_duration": 3.7451419830322266,
"step2_duration": 1.131227970123291,
"step3_duration": 12.550342082977295,
"total_duration": 17.426751136779785,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 306,
"message_count": 6,
"config_name": "Reasoning Effort: High",
"timestamp": 1753575932.0620391
},
{
"success": true,
"error": null,
"step1_duration": 3.0755691528320312,
"step2_duration": 3.7900118827819824,
"step3_duration": 8.599286079406738,
"total_duration": 15.464945077896118,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 306,
"message_count": 6,
"config_name": "Thinking Budget: 128",
"timestamp": 1753575947.527002
},
{
"success": true,
"error": null,
"step1_duration": 2.970345973968506,
"step2_duration": 4.713220119476318,
"step3_duration": 11.738292932510376,
"total_duration": 19.421957969665527,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 310,
"message_count": 6,
"config_name": "Thinking Budget: 1024",
"timestamp": 1753575966.948982
}
],
"fastest_configs": [
{
"success": true,
"error": null,
"step1_duration": 2.797980308532715,
"step2_duration": 1.8835067749023438e-05,
"step3_duration": 2.499279260635376,
"total_duration": 5.2979230880737305,
"tool_call_success": true,
"tool_call_result": "5670.0",
"result_correct": false,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 86,
"message_count": 6,
"config_name": "Old API (No Thinking)",
"timestamp": 1753575680.1571221
},
{
"success": true,
"error": null,
"step1_duration": 1.8824458122253418,
"step2_duration": 1.5384819507598877,
"step3_duration": 2.318272113800049,
"total_duration": 5.739390850067139,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 5,
"step3_response_length": 160,
"message_count": 6,
"config_name": "New API - Thinking Budget: 128",
"timestamp": 1753575685.896559
},
{
"success": true,
"error": null,
"step1_duration": 2.030133008956909,
"step2_duration": 1.9902338981628418,
"step3_duration": 2.3604180812835693,
"total_duration": 6.380887031555176,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 277,
"message_count": 6,
"config_name": "LiteLLM - Thinking Budget: 128",
"timestamp": 1753575753.83583
},
{
"success": true,
"error": null,
"step1_duration": 2.7450361251831055,
"step2_duration": 1.0403151512145996,
"step3_duration": 5.529464960098267,
"total_duration": 9.314986944198608,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 36,
"step3_response_length": 153,
"message_count": 6,
"config_name": "New API - Thinking Budget: 1024",
"timestamp": 1753575695.211576
},
{
"success": true,
"error": null,
"step1_duration": 3.2801640033721924,
"step2_duration": 1.226274013519287,
"step3_duration": 5.528562068939209,
"total_duration": 10.035185813903809,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 7,
"step3_response_length": 131,
"message_count": 6,
"config_name": "New API - Thinking Budget: 4096",
"timestamp": 1753575705.246801
}
],
"slowest_configs": [
{
"success": true,
"error": null,
"step1_duration": 2.970345973968506,
"step2_duration": 4.713220119476318,
"step3_duration": 11.738292932510376,
"total_duration": 19.421957969665527,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 310,
"message_count": 6,
"config_name": "Thinking Budget: 1024",
"timestamp": 1753575966.948982
},
{
"success": true,
"error": null,
"step1_duration": 2.8680617809295654,
"step2_duration": 4.986494064331055,
"step3_duration": 11.908216714859009,
"total_duration": 19.762842893600464,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 34,
"step3_response_length": 303,
"message_count": 6,
"config_name": "OpenHands Style (Streaming)",
"timestamp": 1753575876.358408
},
{
"success": true,
"error": null,
"step1_duration": 4.210190773010254,
"step2_duration": 7.360184669494629,
"step3_duration": 9.522583961486816,
"total_duration": 21.093040704727173,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 283,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: Low",
"timestamp": 1753575726.339884
},
{
"success": true,
"error": null,
"step1_duration": 3.9966609477996826,
"step2_duration": 1.2283189296722412,
"step3_duration": 15.889936923980713,
"total_duration": 21.115014791488647,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 35,
"step3_response_length": 0,
"message_count": 6,
"config_name": "LiteLLM - Reasoning Effort: High",
"timestamp": 1753575747.454922
},
{
"success": true,
"error": null,
"step1_duration": 4.199495792388916,
"step2_duration": 11.224999904632568,
"step3_duration": 6.673478841781616,
"total_duration": 22.098058938980103,
"tool_call_success": true,
"tool_call_result": "5670",
"result_correct": true,
"step1_response_length": 0,
"step2_response_length": 0,
"step3_response_length": 280,
"message_count": 6,
"config_name": "Reasoning Effort: Medium",
"timestamp": 1753575914.6352708
}
],
"performance_analysis": {
"fastest_time": 5.2979230880737305,
"slowest_time": 22.098058938980103,
"average_time": 14.999442055821419,
"median_time": 17.284837007522583,
"total_successful_tests": 16,
"success_rate": 100.0
}
}
}

View File

@@ -58,8 +58,8 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
# Default is 60000, but we've seen up to 200000
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
RUN groupadd app
RUN useradd -l -m -u $OPENHANDS_USER_ID -s /bin/bash openhands && \
RUN groupadd --gid $OPENHANDS_USER_ID app
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
usermod -aG app openhands && \
usermod -aG sudo openhands && \
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers

View File

@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
unset WORKSPACE_BASE
fi
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
echo "Downloading and installing third_party_runtimes..."
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
echo "third_party_runtimes installed successfully."
else
echo "Failed to install third_party_runtimes." >&2
exit 1
fi
fi
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
echo "Running OpenHands as root"
export RUN_AS_OPENHANDS=false

View File

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

View File

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

View File

@@ -37,7 +37,16 @@
"usage/cloud/bitbucket-installation",
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation",
"usage/cloud/slack-installation"
"usage/cloud/slack-installation",
{
"group": "Project Management Tools",
"pages": [
"usage/cloud/project-management/overview",
"usage/cloud/project-management/jira-integration",
"usage/cloud/project-management/jira-dc-integration",
"usage/cloud/project-management/linear-integration"
]
}
]
},
"usage/cloud/cloud-ui",
@@ -62,6 +71,7 @@
{
"group": "Providers",
"pages": [
"usage/llms/openhands-llms",
"usage/llms/azure-llms",
"usage/llms/google-llms",
"usage/llms/groq",
@@ -69,7 +79,6 @@
"usage/llms/litellm-proxy",
"usage/llms/moonshot",
"usage/llms/openai-llms",
"usage/llms/openhands-llms",
"usage/llms/openrouter"
]
}

BIN
docs/static/img/workspace-admin-edit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
docs/static/img/workspace-configure.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
docs/static/img/workspace-link.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/static/img/workspace-user-edit.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,118 @@
---
title: Jira Data Center Integration (Beta)
description: Complete guide for setting up Jira Data Center integration with OpenHands Cloud, including service account creation, personal access token generation, webhook configuration, and workspace integration setup.
---
# Jira Data Center Integration
## Platform Configuration
### Step 1: Create Service Account
1. **Access User Management**
- Log in to Jira Data Center as administrator
- Go to **Administration** > **User Management**
2. **Create User**
- Click **Create User**
- Username: `openhands-agent`
- Full Name: `OpenHands Agent`
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
- Password: Set a secure password
- Click **Create**
3. **Assign Permissions**
- Add user to appropriate groups
- Ensure access to relevant projects
- Grant necessary project permissions
### Step 2: Generate API Token
1. **Personal Access Tokens**
- Log in as the service account
- Go to **Profile** > **Personal Access Tokens**
- Click **Create token**
- Name: `OpenHands Cloud Integration`
- Expiry: Set appropriate expiration (recommend 1 year)
- Click **Create**
- **Important**: Copy and store the token securely
### Step 3: Configure Webhook
1. **Create Webhook**
- Go to **Administration** > **System** > **WebHooks**
- Click **Create a WebHook**
- **Name**: `OpenHands Cloud Integration`
- **URL**: `https://app.all-hands.dev/integration/jira-dc/events`
- Set a suitable webhook secret
- **Issue related events**: Select the following:
- Issue updated
- Comment created
- **JQL Filter**: Leave empty (or customize as needed)
- Click **Create**
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
---
## Workspace Integration
### Step 1: Log in to OpenHands Cloud
1. **Navigate and Authenticate**
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
### Step 2: Configure Jira Data Center Integration
1. **Access Integration Settings**
- Navigate to **Settings** > **Integrations**
- Locate **Jira Data Center** section
2. **Configure Workspace**
- Click **Configure** button
- Enter your workspace name and click **Connect**
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
- **Webhook Secret**: The webhook secret from Step 3 above
- **Service Account Email**: The service account email from Step 1 above
- **Service Account API Key**: The personal access token from Step 2 above
- Ensure **Active** toggle is enabled
3. **Complete OAuth Flow**
- You'll be redirected to Jira Data Center to complete OAuth verification
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
### Managing Your Integration
**Edit Configuration:**
- Click the **Edit** button next to your configured platform
- Update any necessary credentials or settings
- Click **Update** to apply changes
- You will need to repeat the OAuth flow as before
- **Important:** Only the original user who created the integration can see the edit view
**Unlink Workspace:**
- In the edit view, click **Unlink** next to the workspace name
- This will deactivate your workspace link
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
### Screenshots
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,123 @@
---
title: Jira Cloud Integration
description: Complete guide for setting up Jira Cloud integration with OpenHands Cloud, including service account creation, API token generation, webhook configuration, and workspace integration setup.
---
# Jira Cloud Integration
## Platform Configuration
### Step 1: Create Service Account
1. **Navigate to User Management**
- Go to [Atlassian Admin](https://admin.atlassian.com/)
- Select your organization
- Go to **Directory** > **Users**
2. **Create OpenHands Service Account**
- Click **Add user**
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
- Display name: `OpenHands Agent`
- Send invitation: **No** (you'll set password manually)
- Click **Add user**
3. **Configure Account**
- Locate the created user and click on it
- Set a secure password
- Add to relevant Jira projects with appropriate permissions
### Step 2: Generate API Token
1. **Access API Token Management**
- Log in as the OpenHands service account
- Go to [API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens)
2. **Create API Token**
- Click **Create API token**
- Label: `OpenHands Cloud Integration`
- Expiry: Set appropriate expiration (recommend 1 year)
- Click **Create**
- **Important**: Copy and securely store the token immediately
### Step 3: Configure Webhook
1. **Navigate to Webhook Settings**
- Go to **Jira Settings** > **System** > **WebHooks**
- Click **Create a WebHook**
2. **Configure Webhook**
- **Name**: `OpenHands Cloud Integration`
- **Status**: Enabled
- **URL**: `https://app.all-hands.dev/integration/jira/events`
- **Issue related events**: Select the following:
- Issue updated
- Comment created
- **JQL Filter**: Leave empty (or customize as needed)
- Click **Create**
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
---
## Workspace Integration
### Step 1: Log in to OpenHands Cloud
1. **Navigate and Authenticate**
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
### Step 2: Configure Jira Integration
1. **Access Integration Settings**
- Navigate to **Settings** > **Integrations**
- Locate **Jira Cloud** section
2. **Configure Workspace**
- Click **Configure** button
- Enter your workspace name and click **Connect**
- **Important:** Make sure you enter the full workspace name, eg: **yourcompany.atlassian.net**
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
- **Webhook Secret**: The webhook secret from Step 3 above
- **Service Account Email**: The service account email from Step 1 above
- **Service Account API Key**: The API token from Step 2 above
- Ensure **Active** toggle is enabled
3. **Complete OAuth Flow**
- You'll be redirected to Jira Cloud to complete OAuth verification
- Grant the necessary permissions to verify your workspace access.
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
### Managing Your Integration
**Edit Configuration:**
- Click the **Edit** button next to your configured platform
- Update any necessary credentials or settings
- Click **Update** to apply changes
- You will need to repeat the OAuth flow as before
- **Important:** Only the original user who created the integration can see the edit view
**Unlink Workspace:**
- In the edit view, click **Unlink** next to the workspace name
- This will deactivate your workspace link
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that workspace integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
### Screenshots
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,122 @@
---
title: Linear Integration
description: Complete guide for setting up Linear integration with OpenHands Cloud, including service account creation, API key generation, webhook configuration, and workspace integration setup.
---
# Linear Integration
## Platform Configuration
### Step 1: Create Service Account
1. **Access Team Settings**
- Log in to Linear as a team admin
- Go to **Settings** > **Members**
2. **Invite Service Account**
- Click **Invite members**
- Email: `openhands@yourcompany.com` (replace with your preferred service account email)
- Role: **Member** (with appropriate team access)
- Send invitation
3. **Complete Setup**
- Accept invitation from the service account email
- Complete profile setup
- Ensure access to relevant teams/workspaces
### Step 2: Generate API Key
1. **Access API Settings**
- Log in as the service account
- Go to **Settings** > **API**
2. **Create Personal API Key**
- Click **Create new key**
- Name: `OpenHands Cloud Integration`
- Scopes: Select the following:
- `Read` - Read access to issues and comments
- `Create comments` - Ability to create or update comments
- Select the teams you want to provide access to, or allow access for all teams you have permissions for
- Click **Create**
- **Important**: Copy and store the API key securely
### Step 3: Configure Webhook
1. **Access Webhook Settings**
- Go to **Settings** > **API** > **Webhooks**
- Click **New webhook**
2. **Configure Webhook**
- **Label**: `OpenHands Cloud Integration`
- **URL**: `https://app.all-hands.dev/integration/linear/events`
- **Resource types**: Select:
- `Comment` - For comment events
- `Issue` - For issue updates (label changes)
- Select the teams you want to provide access to, or allow access for all public teams
- Click **Create webhook**
- **Important**: Copy and store the webhook secret securely (you'll need this for workspace integration)
---
## Workspace Integration
### Step 1: Log in to OpenHands Cloud
1. **Navigate and Authenticate**
- Go to [OpenHands Cloud](https://app.all-hands.dev/)
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
### Step 2: Configure Linear Integration
1. **Access Integration Settings**
- Navigate to **Settings** > **Integrations**
- Locate **Linear** section
2. **Configure Workspace**
- Click **Configure** button
- Enter your workspace name and click **Connect**
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
- **Webhook Secret**: The webhook secret from Step 3 above
- **Service Account Email**: The service account email from Step 1 above
- **Service Account API Key**: The API key from Step 2 above
- Ensure **Active** toggle is enabled
3. **Complete OAuth Flow**
- You'll be redirected to Linear to complete OAuth verification
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
### Managing Your Integration
**Edit Configuration:**
- Click the **Edit** button next to your configured platform
- Update any necessary credentials or settings
- Click **Update** to apply changes
- You will need to repeat the OAuth flow as before
- **Important:** Only the original user who created the integration can see the edit view
**Unlink Workspace:**
- In the edit view, click **Unlink** next to the workspace name
- This will deactivate your workspace link
- **Important:** If the original user who configured the integration chooses to unlink their integration, any users currently linked to that integration will also be unlinked, and the workspace integration will be deactivated. The integration can only be reactivated by the original user.
### Screenshots
<AccordionGroup>
<Accordion title="Workspace link flow">
![workspace-link.png](/static/img/workspace-link.png)
</Accordion>
<Accordion title="Workspace Configure flow">
![workspace-link.png](/static/img/workspace-configure.png)
</Accordion>
<Accordion title="Edit view as a user">
![workspace-link.png](/static/img/workspace-user-edit.png)
</Accordion>
<Accordion title="Edit view as the workspace creator">
![workspace-link.png](/static/img/workspace-admin-edit.png)
</Accordion>
</AccordionGroup>

View File

@@ -0,0 +1,79 @@
---
title: Project Management Tool Integrations
description: Overview of OpenHands Cloud integrations with project management platforms including Jira Cloud, Jira Data Center, and Linear. Learn about setup requirements, usage methods, and troubleshooting.
---
# Project Management Tool Integrations
## Overview
OpenHands Cloud integrates with project management platforms (Jira Cloud, Jira Data Center, and Linear) to enable AI-powered task delegation. Users can invoke the OpenHands agent by:
- Adding `@openhands` in ticket comments
- Adding the `openhands` label to tickets
## Prerequisites
Integration requires two levels of setup:
1. **Platform Configuration** - Administrative setup of service accounts and webhooks on your project management platform (see individual platform documentation below)
2. **Workspace Integration** - Self-service configuration through the OpenHands Cloud UI to link your OpenHands account to the target workspace
### Platform-Specific Setup Guides:
- [Jira Cloud Integration](./jira-integration.md)
- [Jira Data Center Integration](./jira-dc-integration.md)
- [Linear Integration](./linear-integration.md)
## Usage
Once both the platform configuration and workspace integration are completed, users can trigger the OpenHands agent within their project management platforms using two methods:
### Method 1: Comment Mention
Add a comment to any issue with `@openhands` followed by your task description:
```
@openhands Please implement the user authentication feature described in this ticket
```
### Method 2: Label-based Delegation
Add the label `openhands` to any issue. The OpenHands agent will automatically process the issue based on its description and requirements.
### Git Repository Detection
The OpenHands agent needs to identify which Git repository to work with when processing your issues. Here's how to ensure proper repository detection:
#### Specifying the Target Repository
**Required:** Include the target Git repository in your issue description or comment to ensure the agent works with the correct codebase.
**Supported Repository Formats:**
- Full HTTPS URL: `https://github.com/owner/repository.git`
- GitHub URL without .git: `https://github.com/owner/repository`
- Owner/repository format: `owner/repository`
#### Platform-Specific Behavior
**Linear Integration:** When GitHub integration is enabled for your Linear workspace with issue sync activated, the target repository is automatically detected from the linked GitHub issue. Manual specification is not required in this configuration.
**Jira Integrations:** Always include the repository information in your issue description or `@openhands` comment to ensure proper repository detection.
## Troubleshooting
### Platform Configuration Issues
- **Webhook not triggering**: Verify the webhook URL is correct and the proper event types are selected (Comment, Issue updated)
- **API authentication failing**: Check API key/token validity and ensure required scopes are granted
- **Permission errors**: Ensure the service account has access to relevant projects/teams and appropriate permissions
### Workspace Integration Issues
- **Workspace linking requests credentials**: If there are no active workspace integrations for the workspace you specified, you need to configure it first. Contact your platform administrator that you want to integrate with (eg: Jira, Linear)
- **OAuth flow fails**: Ensure you're signing in with the same Git provider account that contains the repositories you want OpenHands to work on
- **Integration not found**: Verify the workspace name matches exactly and that platform configuration was completed first
### General Issues
- **Agent not responding**: Check webhook logs in your platform settings and verify service account status
- **Authentication errors**: Verify Git provider permissions and OpenHands Cloud access
- **Partial functionality**: Ensure both platform configuration and workspace integration are properly completed
### Getting Help
For additional support, contact OpenHands Cloud support with:
- Your integration platform (Linear, Jira Cloud, or Jira Data Center)
- Workspace name
- Error logs from webhook/integration attempts
- Screenshots of configuration settings (without sensitive credentials)

View File

@@ -12,6 +12,10 @@ description: This guide walks you through installing the OpenHands Slack app.
allowFullScreen>
</iframe>
<Info>
OpenHands utilizes a large language model (LLM), which may generate responses that are inaccurate or incomplete. While we strive for accuracy, OpenHands' outputs are not guaranteed to be correct, and we encourage users to validate critical information independently.
</Info>
## Prerequisites
- Access to OpenHands Cloud.

View File

@@ -20,7 +20,7 @@ for scripting.
### Running with Python
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported)
**Note** - OpenHands requires Python version 3.12 or higher (Python 3.14 is not currently supported) and `uvx` for the default `fetch` MCP server (more details below).
1. Install OpenHands using pip:
```bash
@@ -103,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
```bash
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -112,7 +112,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
python -m openhands.cli.main --override-cli-mode true
```
@@ -186,7 +186,7 @@ To configure Model Context Protocol (MCP) servers, you can refer to the document
This command modifies your `~/.openhands/config.toml` file and will prompt you to restart OpenHands for changes to take effect.
To enable the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) search engine, you can set the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
By default, the [Fetch MCP server](https://github.com/modelcontextprotocol/servers/tree/main/src/fetch) will be automatically configured for OpenHands. You can also [enable search engine](../search-engine-setup) via the [Tavily MCP server](https://github.com/tavily-ai/tavily-mcp) by setting the `search_api_key` under the `[core]` section in the `~/.openhands/config.toml` file.
##### Example of the `config.toml` file with MCP server configuration:

View File

@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
# Run OpenHands
docker run -it \
--pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -73,7 +73,7 @@ docker run -it \
-v ~/.openhands:/.openhands \
--add-host host.docker.internal:host-gateway \
--name openhands-app-$(date +%Y%m%d%H%M%S) \
docker.all-hands.dev/all-hands-ai/openhands:0.50 \
docker.all-hands.dev/all-hands-ai/openhands:0.51 \
python -m openhands.core.main -t "write a bash script that prints hi"
```

View File

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

View File

@@ -30,5 +30,6 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
## Pricing
Pricing follows official API provider rates.
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
Pricing follows official API provider rates. [You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
For `qwen3-coder-480b`, we charge the cheapest FP8 rate available on openrouter: $0.4 per million input tokens and $1.6 per million output tokens.

View File

@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
### Start the App
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.50-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.51-nikolaik \
-e LOG_ALL_EVENTS=true \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ~/.openhands:/.openhands \
-p 3000:3000 \
--add-host host.docker.internal:host-gateway \
--name openhands-app \
docker.all-hands.dev/all-hands-ai/openhands:0.50
docker.all-hands.dev/all-hands-ai/openhands:0.51
```
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
@@ -100,6 +100,16 @@ OpenHands requires an API key to access most language models. Here's how to get
<AccordionGroup>
<Accordion title="OpenHands (Recommended)">
1. [Log in to OpenHands Cloud](https://app.all-hands.dev).
2. Go to the Settings page and navigate to the `API Keys` tab.
3. Copy your `LLM API Key`.
OpenHands provides access to state-of-the-art agentic coding models with competitive pricing. [Learn more about OpenHands LLM provider](/usage/llms/openhands-llms).
</Accordion>
<Accordion title="Anthropic (Claude)">
1. [Create an Anthropic account](https://console.anthropic.com/).

View File

@@ -183,24 +183,7 @@ The final results will be saved to `evaluation/evaluation_outputs/outputs/swe_be
- `report.json`: a JSON file that contains keys like `"resolved_ids"` pointing to instance IDs that are resolved by the agent.
- `logs/`: a directory of test logs
### Run evaluation with `RemoteRuntime`
OpenHands Remote Runtime is currently in beta (read [here](https://runtime.all-hands.dev/) for more details), it allows you to run rollout in parallel in the cloud, so you don't need a powerful machine to run evaluation.
Fill out [this form](https://docs.google.com/forms/d/e/1FAIpQLSckVz_JFwg2_mOxNZjCtr7aoBFI2Mwdan3f75J_TrdMS1JV2g/viewform) to apply if you want to try this out!
```bash
./evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh [output.jsonl filepath] [num_workers]
# Example - This evaluates patches generated by CodeActAgent on Llama-3.1-70B-Instruct-Turbo on "princeton-nlp/SWE-bench_Lite"'s test set, with 16 number of workers running in parallel
ALLHANDS_API_KEY="YOUR-API-KEY" RUNTIME=remote SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev" EVAL_DOCKER_IMAGE_PREFIX="us-central1-docker.pkg.dev/evaluation-092424/swe-bench-images" \
evaluation/benchmarks/swe_bench/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/swe-bench-lite/CodeActAgent/Llama-3.1-70B-Instruct-Turbo_maxiter_100_N_v1.9-no-hint/output.jsonl 16 "princeton-nlp/SWE-bench_Lite" "test"
```
To clean-up all existing runtimes that you've already started, run:
```bash
ALLHANDS_API_KEY="YOUR-API-KEY" ./evaluation/utils/scripts/cleanup_remote_runtime.sh
```
## SWT-Bench Evaluation

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
INPUT_FILE=$1
NUM_WORKERS=$2
DATASET=$3
SPLIT=$4
if [ -z "$INPUT_FILE" ]; then
echo "INPUT_FILE not specified (should be a path to a jsonl file)"
exit 1
fi
if [ -z "$DATASET" ]; then
echo "DATASET not specified, use default princeton-nlp/SWE-bench_Lite"
DATASET="princeton-nlp/SWE-bench_Lite"
fi
if [ -z "$SPLIT" ]; then
echo "SPLIT not specified, use default test"
SPLIT="test"
fi
if [ -z "$NUM_WORKERS" ]; then
echo "NUM_WORKERS not specified, use default 1"
NUM_WORKERS=1
fi
echo "... Evaluating on $INPUT_FILE ..."
COMMAND="poetry run python evaluation/benchmarks/swe_bench/eval_infer.py \
--eval-num-workers $NUM_WORKERS \
--input-file $INPUT_FILE \
--dataset $DATASET \
--split $SPLIT"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND
# update the output with evaluation results
poetry run python evaluation/benchmarks/swe_bench/scripts/eval/update_output_with_eval.py $INPUT_FILE

View File

@@ -5,8 +5,7 @@ pynguin_ids = ['pydata__xarray-6548-16541', 'pydata__xarray-7003-16557', 'pydata
ids = ['pydata__xarray-3114-16452', 'pydata__xarray-3151-16453', 'pydata__xarray-3156-16454', 'pydata__xarray-3239-16456', 'pydata__xarray-3239-16457', 'pydata__xarray-3239-16458', 'pydata__xarray-3302-16459', 'pydata__xarray-3364-16461', 'pydata__xarray-3677-16471', 'pydata__xarray-3905-16478', 'pydata__xarray-4182-16484', 'pydata__xarray-4248-16486', 'pydata__xarray-4339-16487', 'pydata__xarray-4419-16488', 'pydata__xarray-4629-16492', 'pydata__xarray-4750-16496', 'pydata__xarray-4802-16505', 'pydata__xarray-4966-16515', 'pydata__xarray-4994-16516', 'pydata__xarray-5033-16517', 'pydata__xarray-5126-16518', 'pydata__xarray-5126-16519', 'pydata__xarray-5131-16520', 'pydata__xarray-5365-16529', 'pydata__xarray-5455-16530', 'pydata__xarray-5662-16532', 'pydata__xarray-5731-16534', 'pydata__xarray-6135-16535', 'pydata__xarray-6135-16536', 'pydata__xarray-6386-16537', 'pydata__xarray-6394-16538', 'pydata__xarray-6400-16539', 'pydata__xarray-6461-16540', 'pydata__xarray-6548-16541', 'pydata__xarray-6599-16543', 'pydata__xarray-6601-16544', 'pydata__xarray-6882-16548', 'pydata__xarray-6889-16549', 'pydata__xarray-7003-16557', 'pydata__xarray-7147-16571', 'pydata__xarray-7150-16572', 'pydata__xarray-7203-16577', 'pydata__xarray-7229-16578', 'pydata__xarray-7393-16581', 'pydata__xarray-7400-16582']
Command eval (our approach):
poetry run ./evaluation/benchmarks/testgeneval/scripts/eval_infer_remote.sh evaluation/evaluation_outputs/outputs/kjain14__testgeneval-test/CodeActAgent/gpt-4o_maxiter_25_N_v0.20.0-no-hint-run_1/output.jsonl 10 kjain14/testgeneval test true
Command run (our approach):
./evaluation/benchmarks/testgeneval/scripts/run_infer.sh llm.eval_gpt HEAD CodeActAgent -1 25 10 kjain14/testgeneval test 1 ../TestGenEval/results/testgeneval/preds/gpt-4o-2024-08-06__testgeneval__0.2__test.jsonl

View File

@@ -120,6 +120,9 @@ describe("ExpandableMessage", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const RouterStub = createRoutesStub([

View File

@@ -28,7 +28,6 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},
@@ -114,7 +113,6 @@ describe("EventMessage", () => {
action: "finish" as const,
args: {
final_thought: "Task completed successfully",
task_completed: "success" as const,
outputs: {},
thought: "Task completed successfully",
},

View File

@@ -85,17 +85,36 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the search function that's used by the dropdown
vi.spyOn(OpenHands, "searchGitRepositories").mockResolvedValue(
MOCK_RESPOSITORIES,
);
renderRepoConnector();
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then interact with the repository dropdown
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
screen.getByText("rbren/polaris");
screen.getByText("All-Hands-AI/OpenHands");
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
expect(screen.getByText("All-Hands-AI/OpenHands")).toBeInTheDocument();
});
});
@@ -104,18 +123,47 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
const launchButton = await screen.findByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
expect(launchButton).toBeEnabled();
});
@@ -180,7 +228,10 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
renderRepoConnector();
@@ -192,14 +243,37 @@ describe("RepoConnector", () => {
// repo not selected yet
expect(createConversationSpy).not.toHaveBeenCalled();
// select a repository from the dropdown
const dropdown = await waitFor(() =>
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown"),
);
await userEvent.click(dropdown);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
const repoOption = screen.getByText("rbren/polaris");
await userEvent.click(repoOption);
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
@@ -218,17 +292,46 @@ describe("RepoConnector", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
renderRepoConnector();
const launchButton = await screen.findByTestId("repo-launch-button");
// Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
await userEvent.click(dropdown);
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
expect(screen.getByText("rbren/polaris")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("rbren/polaris"));
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
expect(launchButton).toBeDisabled();
expect(launchButton).toHaveTextContent(/Loading/i);

View File

@@ -12,6 +12,8 @@ const mockUseCreateConversation = vi.fn();
const mockUseIsCreatingConversation = vi.fn();
const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseUserProviders = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
@@ -30,6 +32,29 @@ mockUseIsCreatingConversation.mockReturnValue(false);
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
// Default mock for useGitRepositories
mockUseGitRepositories.mockReturnValue({
data: { pages: [] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
vi.mock("react-i18next", () => ({
useTranslation: () => mockUseTranslation(),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
}));
mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -71,6 +96,10 @@ vi.mock("react-router", async (importActual) => ({
useNavigate: vi.fn(),
}));
vi.mock("#/hooks/query/use-git-repositories", () => ({
useGitRepositories: () => mockUseGitRepositories(),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
@@ -96,34 +125,6 @@ describe("RepositorySelectionForm", () => {
vi.clearAllMocks();
});
it("shows loading indicator when repositories are being fetched", () => {
const MOCK_REPOS: GitRepository[] = [
{
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
renderForm();
// Check if loading indicator is displayed
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
});
it("shows dropdown when repositories are loaded", async () => {
const MOCK_REPOS: GitRepository[] = [
{
@@ -139,24 +140,30 @@ describe("RepositorySelectionForm", () => {
is_public: true,
},
];
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
renderForm();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to load"),
);
mockUseGitRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
renderForm();
@@ -194,40 +201,45 @@ describe("RepositorySelectionForm", () => {
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
renderForm();
const input = await screen.findByTestId("repo-dropdown");
await userEvent.click(input);
for (const repo of MOCK_REPOS) {
expect(screen.getByText(repo.full_name)).toBeInTheDocument();
}
expect(
screen.queryByText(MOCK_SEARCH_REPOS[0].full_name),
).not.toBeInTheDocument();
expect(searchGitReposSpy).not.toHaveBeenCalled();
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
expect(
screen.getByText(MOCK_SEARCH_REPOS[0].full_name),
).toBeInTheDocument();
for (const repo of MOCK_REPOS) {
expect(screen.queryByText(repo.full_name)).not.toBeInTheDocument();
}
});
it("should call onRepoSelection when a searched repository is selected", async () => {
@@ -243,20 +255,26 @@ describe("RepositorySelectionForm", () => {
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
fetchNextPage: vi.fn(),
onLoadMore: vi.fn(),
});
renderForm();
const input = await screen.findByTestId("repo-dropdown");
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector('input[type="text"]') as HTMLInputElement;
expect(input).toBeInTheDocument();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
);
const searchedRepo = screen.getByText(MOCK_SEARCH_REPOS[0].full_name);
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
});
});

View File

@@ -73,7 +73,7 @@ describe("TaskCard", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {

View File

@@ -12,6 +12,23 @@ import { GitRepository } from "#/types/git";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
// Mock hooks
const mockUseUserProviders = vi.fn();
const mockUseUserRepositories = vi.fn();
const mockUseConfig = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
}));
vi.mock("#/hooks/query/use-user-repositories", () => ({
useUserRepositories: () => mockUseUserRepositories(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => mockUseConfig(),
}));
describe("MicroagentManagement", () => {
const RouterStub = createRoutesStub([
{
@@ -105,22 +122,12 @@ describe("MicroagentManagement", () => {
const mockMicroagents: RepositoryMicroagent[] = [
{
name: "test-microagent-1",
type: "repo",
content: "Test microagent content 1",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-1",
},
{
name: "test-microagent-2",
type: "knowledge",
content: "Test microagent content 2",
triggers: ["knowledge", "test"],
inputs: [],
tools: [],
created_at: "2021-10-02T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent-2",
@@ -161,10 +168,39 @@ describe("MicroagentManagement", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
// Setup default hook mocks
mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: mockRepositories,
nextPage: null,
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
mockUseConfig.mockReturnValue({
data: {
APP_MODE: "oss",
},
});
// Setup default mock for retrieveUserGitRepositories
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
...mockRepositories,
]);
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue({
data: [...mockRepositories],
nextPage: null,
});
// Setup default mock for getRepositoryMicroagents
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
...mockMicroagents,
@@ -173,6 +209,13 @@ describe("MicroagentManagement", () => {
vi.spyOn(OpenHands, "searchConversations").mockResolvedValue([
...mockConversations,
]);
// Setup default mock for getRepositoryMicroagentContent
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: ["test", "update"],
});
});
it("should render the microagent management page", async () => {
@@ -183,13 +226,15 @@ describe("MicroagentManagement", () => {
});
it("should display loading state when fetching repositories", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockImplementation(
() => new Promise(() => {}), // Never resolves
);
// Mock loading state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
renderMicroagentManagement();
@@ -199,19 +244,21 @@ describe("MicroagentManagement", () => {
});
it("should handle error when fetching repositories", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockRejectedValue(
new Error("Failed to fetch repositories"),
);
// Mock error state
mockUseUserRepositories.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
renderMicroagentManagement();
// Wait for the error to be handled
await waitFor(() => {
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
});
@@ -220,7 +267,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that tabs are rendered
@@ -238,7 +285,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded and rendered
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that repository names are displayed
@@ -253,7 +300,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -290,7 +337,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -315,7 +362,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -340,7 +387,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -363,7 +410,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -402,7 +449,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
@@ -416,7 +463,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -435,7 +482,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -455,17 +502,28 @@ describe("MicroagentManagement", () => {
});
it("should display empty state when no repositories are found", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
// Mock empty repositories
mockUseUserRepositories.mockReturnValue({
data: {
pages: [
{
data: [],
nextPage: null,
},
],
},
isLoading: false,
isError: false,
hasNextPage: false,
isFetchingNextPage: false,
onLoadMore: vi.fn(),
});
renderMicroagentManagement();
// Wait for repositories to be loaded
await waitFor(() => {
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that empty state messages are displayed
@@ -482,7 +540,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -523,7 +581,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that search input is rendered
@@ -543,7 +601,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Initially only repositories with .openhands should be visible
@@ -574,7 +632,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input with uppercase
@@ -597,7 +655,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input with partial match
@@ -623,7 +681,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input
@@ -656,7 +714,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input with non-existent repository name
@@ -684,7 +742,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input with special characters
@@ -705,7 +763,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Filter to show only repo2
@@ -740,7 +798,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Type in search input with leading/trailing whitespace
@@ -760,7 +818,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
const searchInput = screen.getByRole("textbox", {
@@ -792,7 +850,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -819,7 +877,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -865,7 +923,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -882,7 +940,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -907,7 +965,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -956,7 +1014,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -992,7 +1050,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1034,7 +1092,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1071,7 +1129,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1115,7 +1173,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1145,7 +1203,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1168,7 +1226,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1187,17 +1245,6 @@ describe("MicroagentManagement", () => {
expect(conversation1).toBeInTheDocument();
expect(conversation2).toBeInTheDocument();
// Check that created dates are displayed for conversations (there are multiple elements with the same text)
const createdDates = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/01\/2021/,
);
expect(createdDates.length).toBeGreaterThan(0);
const createdDates2 = screen.getAllByText(
/COMMON\$CREATED_ON.*10\/02\/2021/,
);
expect(createdDates2.length).toBeGreaterThan(0);
});
it("should handle multiple repository expansions with conversations", async () => {
@@ -1206,7 +1253,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion
@@ -1247,7 +1294,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Check that add microagent buttons are present
@@ -1261,7 +1308,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1316,7 +1363,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1340,7 +1387,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1363,7 +1410,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1396,7 +1443,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1423,7 +1470,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1449,7 +1496,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click the first add microagent button
@@ -1475,11 +1522,6 @@ describe("MicroagentManagement", () => {
describe("MicroagentManagementMain", () => {
const mockRepositoryMicroagent: RepositoryMicroagent = {
name: "test-microagent",
type: "repo",
content: "Test microagent content",
triggers: ["test", "microagent"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/test-microagent",
@@ -1533,8 +1575,8 @@ describe("MicroagentManagement", () => {
pr_number: null,
};
const renderMicroagentManagementMain = (selectedMicroagentItem: any) => {
return renderWithProviders(<MicroagentManagementMain />, {
const renderMicroagentManagementMain = (selectedMicroagentItem: any) =>
renderWithProviders(<MicroagentManagementMain />, {
preloadedState: {
metrics: {
cost: null,
@@ -1560,7 +1602,6 @@ describe("MicroagentManagement", () => {
},
},
});
};
it("should render MicroagentManagementDefault when no microagent or conversation is selected", async () => {
renderMicroagentManagementMain(null);
@@ -1820,11 +1861,6 @@ describe("MicroagentManagement", () => {
it("should handle microagent with all required properties", async () => {
const completeMicroagent: RepositoryMicroagent = {
name: "complete-microagent",
type: "knowledge",
content: "Complete microagent content with all properties",
triggers: ["complete", "test"],
inputs: ["input1", "input2"],
tools: ["tool1", "tool2"],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/complete-microagent",
@@ -1874,11 +1910,6 @@ describe("MicroagentManagement", () => {
describe("Update microagent functionality", () => {
const mockMicroagentForUpdate: RepositoryMicroagent = {
name: "update-test-microagent",
type: "repo",
content: "Original microagent content for testing updates",
triggers: ["original", "test"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/update-test-microagent",
@@ -1999,11 +2030,13 @@ describe("MicroagentManagement", () => {
},
});
// Check that the form fields are populated with existing data
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Original microagent content for testing updates",
);
// Wait for the content to be loaded and form fields to be populated
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Original microagent content for testing updates",
);
});
});
it("should handle update microagent form submission", async () => {
@@ -2207,12 +2240,16 @@ describe("MicroagentManagement", () => {
it("should handle update modal with microagent that has no content", async () => {
const user = userEvent.setup();
const microagentWithoutContent = {
...mockMicroagentForUpdate,
content: "",
};
// Render with update modal visible and microagent without content
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2222,7 +2259,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutContent,
microagent: mockMicroagentForUpdate,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2243,19 +2280,25 @@ describe("MicroagentManagement", () => {
},
});
// Check that the form field is empty
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
// Wait for the content to be loaded and check that the form field is empty
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
});
});
it("should handle update modal with microagent that has no triggers", async () => {
const user = userEvent.setup();
const microagentWithoutTriggers = {
...mockMicroagentForUpdate,
triggers: [],
};
// Render with update modal visible and microagent without triggers
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Original microagent content for testing updates",
path: ".openhands/microagents/update-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with update modal visible and microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2265,7 +2308,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutTriggers,
microagent: mockMicroagentForUpdate,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2312,7 +2355,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories to be loaded
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
// Find and click on the first repository accordion to expand it
@@ -2354,7 +2397,7 @@ describe("MicroagentManagement", () => {
// Wait for repositories and expand accordion
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");
@@ -2397,11 +2440,6 @@ describe("MicroagentManagement", () => {
getRepositoryMicroagentsSpy.mockResolvedValue([
{
name: "test-microagent",
type: "repo",
content: "Test content",
triggers: [],
inputs: [],
tools: [],
created_at: "2021-10-01",
git_provider: "github",
path: ".openhands/microagents/test",
@@ -2412,7 +2450,7 @@ describe("MicroagentManagement", () => {
renderMicroagentManagement();
await waitFor(() => {
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
expect(mockUseUserRepositories).toHaveBeenCalled();
});
const repoAccordion = screen.getByTestId("repository-name-tooltip");
@@ -2486,11 +2524,6 @@ describe("MicroagentManagement", () => {
describe("Learn something new button functionality", () => {
const mockMicroagentForLearn: RepositoryMicroagent = {
name: "learn-test-microagent",
type: "repo",
content: "Test microagent content for learn functionality",
triggers: ["learn", "test"],
inputs: [],
tools: [],
created_at: "2021-10-01T12:00:00Z",
git_provider: "github",
path: ".openhands/microagents/learn-test-microagent",
@@ -2586,6 +2619,14 @@ describe("MicroagentManagement", () => {
it("should populate form fields with current microagent data when learn button is clicked", async () => {
const user = userEvent.setup();
// Mock the content API to return the expected content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: ["learn", "test"],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
@@ -2626,21 +2667,27 @@ describe("MicroagentManagement", () => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that the form fields are populated with current microagent data
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Test microagent content for learn functionality",
);
// Wait for the content to be loaded and form to be populated
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue(
"Test microagent content for learn functionality",
);
});
});
it("should handle learn button click with microagent that has no content", async () => {
const user = userEvent.setup();
const microagentWithoutContent = {
...mockMicroagentForLearn,
content: "",
};
// Render with selected microagent without content
// Mock the content API to return empty content for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2650,7 +2697,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutContent,
microagent: mockMicroagentForLearn,
conversation: undefined,
},
addMicroagentModalVisible: false,
@@ -2680,19 +2727,25 @@ describe("MicroagentManagement", () => {
expect(screen.getByTestId("add-microagent-modal")).toBeInTheDocument();
});
// Check that the form field is empty
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
// Wait for the content to be loaded and check that the form field is empty
await waitFor(() => {
const queryInput = screen.getByTestId("query-input");
expect(queryInput).toHaveValue("");
});
});
it("should handle learn button click with microagent that has no triggers", async () => {
const user = userEvent.setup();
const microagentWithoutTriggers = {
...mockMicroagentForLearn,
triggers: [],
};
// Render with selected microagent without triggers
// Mock the content API to return content without triggers for this test
vi.spyOn(OpenHands, "getRepositoryMicroagentContent").mockResolvedValue({
content: "Test microagent content for learn functionality",
path: ".openhands/microagents/learn-test-microagent",
git_provider: "github",
triggers: [],
});
// Render with selected microagent
renderWithProviders(<RouterStub />, {
preloadedState: {
metrics: {
@@ -2702,7 +2755,7 @@ describe("MicroagentManagement", () => {
},
microagentManagement: {
selectedMicroagentItem: {
microagent: microagentWithoutTriggers,
microagent: mockMicroagentForLearn,
conversation: undefined,
},
addMicroagentModalVisible: false,

View File

@@ -28,6 +28,9 @@ describe("PaymentForm", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
});

View File

@@ -1,27 +1,82 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach } from "vitest";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
.fn()
.mockReturnValue({ data: true, isLoading: false });
const useConfigMock = vi
.fn()
.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
const useUserProvidersMock = vi
.fn()
.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Mock the hooks
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => useIsAuthedMock(),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => useConfigMock(),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => useUserProvidersMock(),
}));
describe("UserActions", () => {
const user = userEvent.setup();
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
// Create a wrapper with QueryClientProvider
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
beforeEach(() => {
// Reset all mocks to default values before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
});
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
vi.clearAllMocks();
});
it("should render", () => {
render(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -43,7 +98,7 @@ describe("UserActions", () => {
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
render(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -62,20 +117,28 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is undefined
// Context menu should NOT appear because user is not authenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should show context menu even when user has no avatar_url", async () => {
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
renderWithQueryClient(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -86,42 +149,88 @@ describe("UserActions", () => {
).toBeInTheDocument();
});
it("should NOT be able to access logout when no user is provided", async () => {
render(<UserActions onLogout={onLogoutMock} />);
it("should NOT be able to access logout when user is not authenticated", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Logout option should not be accessible because context menu doesn't appear
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(onLogoutMock).not.toHaveBeenCalled();
});
it("should handle user prop changing from undefined to defined", () => {
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
// Initially no user - context menu shouldn't work
// Context menu should NOT appear because user is not authenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Add user prop
// Logout option should NOT be accessible when user is not authenticated
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
);
// Initially no user and not authenticated - menu should not appear
let userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
rerender(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
<QueryClientProvider client={queryClient}>
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</QueryClientProvider>,
);
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
});
it("should handle user prop changing from defined to undefined", async () => {
const { rerender } = render(
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -135,16 +244,35 @@ describe("UserActions", () => {
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
// Remove user prop - menu should disappear
rerender(<UserActions onLogout={onLogoutMock} />);
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<QueryClientProvider client={new QueryClient()}>
<UserActions onLogout={onLogoutMock} />
</QueryClientProvider>,
);
// Context menu should NOT be visible when user becomes unauthenticated
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Logout option should not be accessible
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
render(
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}

View File

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

View File

@@ -76,6 +76,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -111,6 +114,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -192,6 +198,9 @@ describe("frontend/routes/_oh", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});

View File

@@ -3,6 +3,8 @@ 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 i18next from "i18next";
import { I18nextProvider } from "react-i18next";
import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
@@ -17,6 +19,9 @@ const VALID_OSS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};
@@ -27,6 +32,9 @@ const VALID_SAAS_CONFIG: GetConfigResponse = {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
};
@@ -40,22 +48,44 @@ const GitSettingsRouterStub = createRoutesStub([
]);
const renderGitSettingsScreen = () => {
// Initialize i18next instance
i18next.init({
lng: "en",
resources: {
en: {
translation: {
GITHUB$TOKEN_HELP_TEXT: "Help text",
GITHUB$TOKEN_LABEL: "GitHub Token",
GITHUB$HOST_LABEL: "GitHub Host",
GITLAB$TOKEN_LABEL: "GitLab Token",
GITLAB$HOST_LABEL: "GitLab Host",
BITBUCKET$TOKEN_LABEL: "Bitbucket Token",
BITBUCKET$HOST_LABEL: "Bitbucket Host",
},
},
},
});
const { rerender, ...rest } = render(
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />,
{
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</I18nextProvider>
),
},
);
const rerenderGitSettingsScreen = () =>
rerender(
<QueryClientProvider client={queryClient}>
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
</QueryClientProvider>,
<I18nextProvider i18n={i18next}>
<QueryClientProvider client={queryClient}>
<GitSettingsRouterStub initialEntries={["/settings/integrations"]} />
</QueryClientProvider>
</I18nextProvider>,
);
return {
@@ -345,14 +375,18 @@ describe("Form submission", () => {
let disconnectButton = await screen.findByTestId(
"disconnect-tokens-button",
);
// When tokens are set (github and gitlab are not null), the button should be enabled
await waitFor(() => expect(disconnectButton).not.toBeDisabled());
// Mock settings with no tokens set
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
queryClient.invalidateQueries();
disconnectButton = await screen.findByTestId("disconnect-tokens-button");
// When no tokens are set, the button should be disabled
await waitFor(() => expect(disconnectButton).toBeDisabled());
});

View File

@@ -32,6 +32,42 @@ const RouterStub = createRoutesStub([
},
]);
const selectRepository = async (repoName: string) => {
const repoConnector = screen.getByTestId("repo-connector");
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const repoInput = within(dropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
const options = screen.getAllByText(repoName);
// Find the option in the dropdown (it will have role="option")
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
expect(dropdownOption).toBeInTheDocument();
});
const options = screen.getAllByText(repoName);
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
await userEvent.click(dropdownOption!);
// Wait for the branch to be auto-selected
await waitFor(() => {
expect(screen.getByText("main")).toBeInTheDocument();
});
};
const renderHomeScreen = () =>
render(<RouterStub />, {
wrapper: ({ children }) => (
@@ -93,84 +129,8 @@ describe("HomeScreen", () => {
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should reset the filtered tasks when the selected repository is cleared", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository from the dropdown
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Clear the selected repository
await userEvent.clear(dropdown);
// All tasks should be visible again
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
});
// TODO: Fix this test
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
@@ -179,19 +139,25 @@ describe("HomeScreen", () => {
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Select a repository from the dropdown to enable the repo launch button
const repoConnector = screen.getByTestId("repo-connector");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
await userEvent.click(dropdown);
const repoOption = screen.getAllByText("octocat/hello-world")[1];
await userEvent.click(repoOption);
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
// Select a repository to enable the repo launch button
await selectRepository("octocat/hello-world");
// Wait for all buttons to be enabled
await waitFor(() => {
expect(headerLaunchButton).not.toBeDisabled();
expect(repoLaunchButton).not.toBeDisabled();
tasksLaunchButtons.forEach((button) => {
expect(button).not.toBeDisabled();
});
});
// Get fresh references to the buttons
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");
@@ -208,7 +174,10 @@ describe("HomeScreen", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue(MOCK_RESPOSITORIES);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
});
it("should disable the other launch buttons when the header launch button is clicked", async () => {
@@ -333,6 +302,9 @@ describe("Settings 404", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const error = createAxiosNotFoundErrorObject();
@@ -355,6 +327,9 @@ describe("Setup Payment modal", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
const error = createAxiosNotFoundErrorObject();

View File

@@ -47,7 +47,7 @@ describe("Content", () => {
const apiKey = screen.getByTestId("llm-api-key-input");
await waitFor(() => {
expect(provider).toHaveValue("Anthropic");
expect(provider).toHaveValue("OpenHands");
expect(model).toHaveValue("claude-sonnet-4-20250514");
expect(apiKey).toHaveValue("");
@@ -135,7 +135,7 @@ describe("Content", () => {
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("anthropic/claude-sonnet-4-20250514");
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
expect(baseUrl).toHaveValue("");
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
@@ -537,7 +537,7 @@ describe("Form submission", () => {
// select provider
await userEvent.click(provider);
const providerOption = screen.getByText("Anthropic");
const providerOption = screen.getByText("OpenHands");
await userEvent.click(providerOption);
// select model
@@ -550,7 +550,7 @@ describe("Form submission", () => {
expect(saveSettingsSpy).toHaveBeenCalledWith(
expect.objectContaining({
llm_model: "anthropic/claude-sonnet-4-20250514",
llm_model: "openhands/claude-sonnet-4-20250514",
llm_base_url: "",
confirmation_mode: false,
}),

View File

@@ -101,7 +101,8 @@ describe("Content", () => {
renderSecretsSettings();
expect(getSecretsSpy).not.toHaveBeenCalled();
// In SAAS mode, getSecrets is still called because the user is authenticated
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
await waitFor(() =>
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
@@ -111,12 +112,21 @@ describe("Content", () => {
screen.getByTestId("git-settings-screen");
});
it("should render a message if there are no existing secrets", async () => {
it("should render an empty table when there are no existing secrets", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
await screen.findByTestId("no-secrets-message");
// Should show the add secret button
await screen.findByTestId("add-secret-button");
// Should show an empty table with headers but no secret items
expect(screen.queryAllByTestId("secret-item")).toHaveLength(0);
// Should still show the table headers
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument();
expect(screen.getByText("SECRETS$DESCRIPTION")).toBeInTheDocument();
expect(screen.getByText("SETTINGS$ACTIONS")).toBeInTheDocument();
});
it("should render existing secrets", async () => {
@@ -126,7 +136,6 @@ describe("Content", () => {
const secrets = await screen.findAllByTestId("secret-item");
expect(secrets).toHaveLength(2);
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
});
});
@@ -398,19 +407,22 @@ describe("Secret actions", () => {
expect(screen.queryByText("My_Secret_2")).toBeInTheDocument();
});
it("should hide the no items message when in form view", async () => {
it("should hide the table and add button when in form view", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
renderSecretsSettings();
// render form & hide items
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
// Initially should show the add button and table
const button = await screen.findByTestId("add-secret-button");
expect(screen.getByText("SETTINGS$NAME")).toBeInTheDocument(); // table header
await userEvent.click(button);
// When in form view, should hide the add button and table
const secretForm = screen.getByTestId("add-secret-form");
expect(secretForm).toBeInTheDocument();
expect(screen.queryByTestId("no-secrets-message")).not.toBeInTheDocument();
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument();
expect(screen.queryByText("SETTINGS$NAME")).not.toBeInTheDocument(); // table header should be hidden
});
it("should not allow spaces in secret names", async () => {

View File

@@ -86,6 +86,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: false,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -104,6 +107,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});
@@ -122,6 +128,9 @@ describe("Settings Billing", () => {
FEATURE_FLAGS: {
ENABLE_BILLING: true,
HIDE_LLM_SETTINGS: false,
ENABLE_JIRA: false,
ENABLE_JIRA_DC: false,
ENABLE_LINEAR: false,
},
});

View File

@@ -82,5 +82,11 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-opus-4-1-20250805")).toEqual({
provider: "anthropic",
model: "claude-opus-4-1-20250805",
separator: "/",
});
});
});

View File

@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="OpenHands: Code Less, Make More"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>OpenHands</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.50.0",
"version": "0.51.1",
"private": true,
"type": "module",
"engines": {
@@ -8,32 +8,33 @@
},
"dependencies": {
"@heroui/react": "^2.8.2",
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.7.1",
"@react-router/serve": "^7.7.1",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.8.1",
"@stripe/stripe-js": "^7.7.0",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query": "^5.84.1",
"@vitejs/plugin-react": "^4.7.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.11",
"framer-motion": "^12.23.12",
"i18next": "^25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.533.0",
"lucide-react": "^0.536.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.258.2",
"posthog-js": "^1.258.5",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -43,6 +44,7 @@
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
@@ -51,7 +53,7 @@
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.6",
"web-vitals": "^5.0.3",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
"scripts": {
@@ -84,15 +86,15 @@
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.54.1",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.7.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.1.0",
"@types/node": "^24.2.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-highlight": "^0.12.8",
@@ -110,18 +112,18 @@
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.2",
"lint-staged": "^16.1.4",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.3.0",
"stripe": "^18.4.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"

View File

@@ -7,7 +7,7 @@
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.10.3'
const PACKAGE_VERSION = '2.10.4'
const INTEGRITY_CHECKSUM = 'f5825c521429caf22a4dd13b66e243af'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()

View File

@@ -14,12 +14,15 @@ import {
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
MicroagentContentResponse,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -166,6 +169,38 @@ class OpenHands {
}
}
/**
* Get feedback for multiple events in a conversation
* @param conversationId The conversation ID
* @returns Map of event IDs to feedback data including existence, rating, reason and metadata
*/
static async getBatchFeedback(conversationId: string): Promise<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
> {
const url = `/feedback/conversation/${conversationId}/batch`;
const { data } = await openHands.get<
Record<
string,
{
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, BatchFeedbackData>;
}
>
>(url);
return data;
}
/**
* Authenticate with GitHub token
* @returns Response with authentication status and user info if successful
@@ -247,7 +282,7 @@ class OpenHands {
static async getUserConversations(): Promise<Conversation[]> {
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/conversations?limit=20",
"/api/conversations?limit=100",
);
return data.results;
}
@@ -400,6 +435,7 @@ class OpenHands {
static async searchGitRepositories(
query: string,
per_page = 5,
selected_provider?: Provider,
): Promise<GitRepository[]> {
const response = await openHands.get<GitRepository[]>(
"/api/user/search/repositories",
@@ -407,6 +443,7 @@ class OpenHands {
params: {
query,
per_page,
selected_provider,
},
},
);
@@ -451,20 +488,70 @@ class OpenHands {
}
/**
* Given a PAT, retrieves the repositories of the user
* @returns A list of repositories
*/
static async retrieveUserGitRepositories() {
static async retrieveUserGitRepositories(
selected_provider: Provider,
page = 1,
per_page = 30,
) {
const { data } = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
},
},
);
return data;
const link =
data.length > 0 && data[0].link_header ? data[0].link_header : "";
const nextPage = extractNextPageFromLink(link);
return { data, nextPage };
}
static async retrieveInstallationRepositories(
selected_provider: Provider,
installationIndex: number,
installations: string[],
page = 1,
per_page = 30,
) {
const installationId = installations[installationIndex];
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
params: {
selected_provider,
sort: "pushed",
page,
per_page,
installation_id: installationId,
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
: "";
const nextPage = extractNextPageFromLink(link);
let nextInstallation: number | null;
if (nextPage) {
nextInstallation = installationIndex;
} else if (installationIndex + 1 < installations.length) {
nextInstallation = installationIndex + 1;
} else {
nextInstallation = null;
}
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
}
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
@@ -491,7 +578,7 @@ class OpenHands {
}
/**
* Get the available microagents for a specific repository
* Get the available microagents for a repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
@@ -506,6 +593,27 @@ class OpenHands {
return data;
}
/**
* Get the content of a specific microagent from a repository
* @param owner The repository owner
* @param repo The repository name
* @param filePath The path to the microagent file within the repository
* @returns The microagent content and metadata
*/
static async getRepositoryMicroagentContent(
owner: string,
repo: string,
filePath: string,
): Promise<MicroagentContentResponse> {
const { data } = await openHands.get<MicroagentContentResponse>(
`/api/user/repository/${owner}/${repo}/microagents/content`,
{
params: { file_path: filePath },
},
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
@@ -531,6 +639,18 @@ class OpenHands {
return data;
}
/**
* Get the user installation IDs
* @param provider The provider to get installation IDs for (github, bitbucket, etc.)
* @returns List of installation IDs
*/
static async getUserInstallationIds(provider: Provider): Promise<string[]> {
const { data } = await openHands.get<string[]>(
`/api/user/installations?provider=${provider}`,
);
return data;
}
}
export default OpenHands;

View File

@@ -51,10 +51,14 @@ export interface GetConfigResponse {
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
AUTH_URL?: string;
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
ENABLE_JIRA: boolean;
ENABLE_JIRA_DC: boolean;
ENABLE_LINEAR: boolean;
};
MAINTENANCE?: {
startTime: string;
@@ -147,3 +151,10 @@ export interface CreateMicroagent {
git_provider?: Provider;
title?: string;
}
export interface MicroagentContentResponse {
content: string;
path: string;
git_provider: Provider;
triggers: string[];
}

View File

@@ -0,0 +1,69 @@
import { useMemo } from "react";
import { useRepositoryBranches } from "../../hooks/query/use-repository-branches";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitBranchDropdownProps {
repositoryName?: string | null;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (branchName: string | null) => void;
}
export function GitBranchDropdown({
repositoryName,
value,
placeholder = "Select branch...",
className,
errorMessage,
disabled = false,
onChange,
}: GitBranchDropdownProps) {
const { data: branches, isLoading } = useRepositoryBranches(
repositoryName || null,
);
const options: SelectOption[] = useMemo(
() =>
branches?.map((branch) => ({
value: branch.name,
label: branch.name,
})) || [],
[branches],
);
const hasNoBranches = !isLoading && branches && branches.length === 0;
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value || null);
};
const isDisabled = disabled || !repositoryName || isLoading || hasNoBranches;
const displayPlaceholder = hasNoBranches ? "No branches found" : placeholder;
const displayErrorMessage = hasNoBranches
? "This repository has no branches"
: errorMessage;
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={displayPlaceholder}
className={className}
errorMessage={displayErrorMessage}
disabled={isDisabled}
isClearable={false}
isSearchable
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { Provider } from "../../types/settings";
import { ReactSelectDropdown, SelectOption } from "./react-select-dropdown";
export interface GitProviderDropdownProps {
providers: Provider[];
value?: Provider | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
isLoading?: boolean;
onChange?: (provider: Provider | null) => void;
}
export function GitProviderDropdown({
providers,
value,
placeholder = "Select Provider",
className,
errorMessage,
disabled = false,
isLoading = false,
onChange,
}: GitProviderDropdownProps) {
const options: SelectOption[] = useMemo(
() =>
providers.map((provider) => ({
value: provider,
label: provider.charAt(0).toUpperCase() + provider.slice(1),
})),
[providers],
);
const selectedOption = useMemo(
() => options.find((option) => option.value === value) || null,
[options, value],
);
const handleChange = (option: SelectOption | null) => {
onChange?.(option?.value as Provider | null);
};
return (
<ReactSelectDropdown
options={options}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isSearchable={false}
isLoading={isLoading}
onChange={handleChange}
/>
);
}

View File

@@ -0,0 +1,186 @@
import { useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Provider } from "../../types/settings";
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
import OpenHands from "../../api/open-hands";
import { GitRepository } from "../../types/git";
import {
ReactSelectAsyncDropdown,
AsyncSelectOption,
} from "./react-select-async-dropdown";
export interface GitRepositoryDropdownProps {
provider: Provider;
value?: string | null;
placeholder?: string;
className?: string;
errorMessage?: string;
disabled?: boolean;
onChange?: (repository?: GitRepository) => void;
}
interface SearchCache {
[key: string]: GitRepository[];
}
export function GitRepositoryDropdown({
provider,
value,
placeholder = "Search repositories...",
className,
errorMessage,
disabled = false,
onChange,
}: GitRepositoryDropdownProps) {
const { t } = useTranslation();
const {
data,
fetchNextPage,
hasNextPage,
isLoading,
isFetchingNextPage,
isError,
} = useGitRepositories({
provider,
enabled: !disabled,
});
const allOptions: AsyncSelectOption[] = useMemo(
() =>
data?.pages
? data.pages.flatMap((page) =>
page.data.map((repo) => ({
value: repo.id,
label: repo.full_name,
})),
)
: [],
[data],
);
// Keep track of search results
const searchCache = useRef<SearchCache>({});
const selectedOption = useMemo(() => {
// First check in loaded pages
const option = allOptions.find((opt) => opt.value === value);
if (option) return option;
// If not found, check in search cache
const repo = Object.values(searchCache.current)
.flat()
.find((r) => r.id === value);
if (repo) {
return {
value: repo.id,
label: repo.full_name,
};
}
return null;
}, [allOptions, value]);
const loadOptions = useCallback(
async (inputValue: string): Promise<AsyncSelectOption[]> => {
// If empty input, show all loaded options
if (!inputValue.trim()) {
return allOptions;
}
// If it looks like a URL, extract the repo name and search
if (inputValue.startsWith("https://")) {
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
if (match) {
const repoName = match[1];
const searchResults = await OpenHands.searchGitRepositories(
repoName,
3,
);
// Cache the search results
searchCache.current[repoName] = searchResults;
return searchResults.map((repo) => ({
value: repo.id,
label: repo.full_name,
}));
}
}
// For any other input, search via API
if (inputValue.length >= 2) {
// Only search if at least 2 characters
const searchResults = await OpenHands.searchGitRepositories(
inputValue,
10,
);
// Cache the search results
searchCache.current[inputValue] = searchResults;
return searchResults.map((repo) => ({
value: repo.id,
label: repo.full_name,
}));
}
// For very short inputs, do local filtering
return allOptions.filter((option) =>
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
},
[allOptions],
);
const handleChange = (option: AsyncSelectOption | null) => {
if (!option) {
onChange?.(undefined);
return;
}
// First check in loaded pages
let repo = data?.pages
?.flatMap((p) => p.data)
.find((r) => r.id === option.value);
// If not found, check in search results
if (!repo) {
repo = Object.values(searchCache.current)
.flat()
.find((r) => r.id === option.value);
}
onChange?.(repo);
};
const handleMenuScrollToBottom = useCallback(() => {
if (hasNextPage && !isFetchingNextPage && !isLoading) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, isLoading, fetchNextPage]);
return (
<>
<ReactSelectAsyncDropdown
testId="repo-dropdown"
loadOptions={loadOptions}
value={selectedOption}
placeholder={placeholder}
className={className}
errorMessage={errorMessage}
disabled={disabled}
isClearable={false}
isLoading={isLoading || isLoading || isFetchingNextPage}
cacheOptions
defaultOptions={allOptions}
onChange={handleChange}
onMenuScrollToBottom={handleMenuScrollToBottom}
/>
{isError && (
<div
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}
</div>
)}
</>
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback, useMemo } from "react";
import AsyncSelect from "react-select/async";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type AsyncSelectOption = SelectOptionBase;
export interface ReactSelectAsyncDropdownProps {
loadOptions: (inputValue: string) => Promise<AsyncSelectOption[]>;
testId?: string;
placeholder?: string;
value?: AsyncSelectOption | null;
defaultValue?: AsyncSelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isLoading?: boolean;
cacheOptions?: boolean;
defaultOptions?: boolean | AsyncSelectOption[];
onChange?: (option: AsyncSelectOption | null) => void;
onMenuScrollToBottom?: () => void;
}
export function ReactSelectAsyncDropdown({
loadOptions,
testId,
placeholder = "Search...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isLoading = false,
cacheOptions = true,
defaultOptions = true,
onChange,
onMenuScrollToBottom,
}: ReactSelectAsyncDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<AsyncSelectOption>(), []);
const handleLoadOptions = useCallback(
(inputValue: string, callback: (options: AsyncSelectOption[]) => void) => {
loadOptions(inputValue)
.then((options) => callback(options))
.catch(() => callback([]));
},
[loadOptions],
);
return (
<div data-testid={testId} className={cn("w-full", className)}>
<AsyncSelect
loadOptions={handleLoadOptions}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isLoading={isLoading}
cacheOptions={cacheOptions}
defaultOptions={defaultOptions}
onChange={onChange}
onMenuScrollToBottom={onMenuScrollToBottom}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p
data-testid="repo-dropdown-error"
className="text-red-500 text-sm mt-1"
>
{errorMessage}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { useMemo } from "react";
import Select from "react-select";
import { cn } from "#/utils/utils";
import { SelectOptionBase, getCustomStyles } from "./react-select-styles";
export type SelectOption = SelectOptionBase;
export interface ReactSelectDropdownProps {
options: SelectOption[];
placeholder?: string;
value?: SelectOption | null;
defaultValue?: SelectOption | null;
className?: string;
errorMessage?: string;
disabled?: boolean;
isClearable?: boolean;
isSearchable?: boolean;
isLoading?: boolean;
onChange?: (option: SelectOption | null) => void;
}
export function ReactSelectDropdown({
options,
placeholder = "Select option...",
value,
defaultValue,
className,
errorMessage,
disabled = false,
isClearable = false,
isSearchable = true,
isLoading = false,
onChange,
}: ReactSelectDropdownProps) {
const customStyles = useMemo(() => getCustomStyles<SelectOption>(), []);
return (
<div className={cn("w-full", className)}>
<Select
options={options}
value={value}
defaultValue={defaultValue}
placeholder={placeholder}
isDisabled={disabled}
isClearable={isClearable}
isSearchable={isSearchable}
isLoading={isLoading}
onChange={onChange}
styles={customStyles}
className="w-full"
/>
{errorMessage && (
<p className="text-red-500 text-sm mt-1">{errorMessage}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { StylesConfig } from "react-select";
export interface SelectOptionBase {
value: string;
label: string;
}
export const getCustomStyles = <T extends SelectOptionBase>(): StylesConfig<
T,
false
> => ({
control: (provided, state) => ({
...provided,
backgroundColor: state.isDisabled ? "#363636" : "#454545", // darker tertiary when disabled
border: "1px solid #717888",
borderRadius: "0.125rem",
minHeight: "2.5rem",
padding: "0 0.5rem",
boxShadow: state.isFocused ? "0 0 0 1px #717888" : "none",
opacity: state.isDisabled ? 0.6 : 1,
cursor: state.isDisabled ? "not-allowed" : "pointer",
"&:hover": {
borderColor: "#717888",
},
}),
input: (provided) => ({
...provided,
color: "#ECEDEE", // content
}),
placeholder: (provided) => ({
...provided,
fontStyle: "italic",
color: "#B7BDC2", // tertiary-light
}),
singleValue: (provided, state) => ({
...provided,
color: state.isDisabled ? "#B7BDC2" : "#ECEDEE", // tertiary-light when disabled, content otherwise
}),
menu: (provided) => ({
...provided,
backgroundColor: "#454545", // tertiary
border: "1px solid #717888",
borderRadius: "0.75rem",
overflow: "hidden", // ensure menu items don't overflow rounded corners
}),
menuList: (provided) => ({
...provided,
padding: "0.25rem", // add some padding around menu items
}),
option: (provided, state) => {
let backgroundColor = "transparent";
if (state.isSelected) {
backgroundColor = "#C9B974"; // primary for selected
} else if (state.isFocused) {
backgroundColor = "#24272E"; // base-secondary for hover/focus
}
return {
...provided,
backgroundColor,
color: state.isSelected ? "#000000" : "#ECEDEE", // black text on yellow, white on gray
borderRadius: "0.5rem", // rounded menu items
margin: "0.125rem 0", // small gap between items
"&:hover": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E", // keep yellow if selected, else gray
color: state.isSelected ? "#000000" : "#ECEDEE", // maintain text color on hover
},
"&:active": {
backgroundColor: state.isSelected ? "#C9B974" : "#24272E",
color: state.isSelected ? "#000000" : "#ECEDEE",
},
};
},
clearIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
dropdownIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
"&:hover": {
color: "#ECEDEE", // content
},
}),
loadingIndicator: (provided) => ({
...provided,
color: "#B7BDC2", // tertiary-light
}),
});

View File

@@ -38,7 +38,7 @@ export function ActionSuggestions({
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Check your current branch name first - if it's main, master, deploy, or another common default branch name, create a new branch with a descriptive name related to your changes. Otherwise, use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. If you're on a default branch (e.g., main, master, deploy), create a new branch with a descriptive name otherwise use the current branch. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};

View File

@@ -55,7 +55,7 @@ export function ChatMessage({
className={cn(
"rounded-xl relative w-fit",
"flex flex-col gap-2",
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
type === "user" && " p-4 bg-tertiary self-end",
type === "agent" && "mt-6 max-w-full bg-transparent",
)}
>
@@ -86,7 +86,13 @@ export function ChatMessage({
/>
</div>
<div className="text-sm break-words">
<div
className="text-sm"
style={{
whiteSpace: "normal",
wordBreak: "break-word",
}}
>
<Markdown
components={{
code,

View File

@@ -77,25 +77,8 @@ const getMcpActionContent = (event: MCPAction): string => {
const getThinkActionContent = (event: ThinkAction): string =>
event.args.thought;
const getFinishActionContent = (event: FinishAction): string => {
let content = event.args.final_thought;
switch (event.args.task_completed) {
case "success":
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_SUCCESSFULLY")}`;
break;
case "failure":
content += `\n\n\n${i18n.t("FINISH$TASK_NOT_COMPLETED")}`;
break;
case "partial":
default:
content += `\n\n\n${i18n.t("FINISH$TASK_COMPLETED_PARTIALLY")}`;
break;
}
return content.trim();
};
const getFinishActionContent = (event: FinishAction): string =>
event.args.final_thought.trim();
const getNoContentActionContent = (): string => "";
export const getActionContent = (event: OpenHandsAction): string => {

View File

@@ -22,7 +22,7 @@ export function AccountSettingsContextMenu({
ref={ref}
className="absolute right-full md:left-full -top-1 z-10 w-fit"
>
<ContextMenuListItem onClick={onLogout}>
<ContextMenuListItem onClick={onLogout} data-testid="logout-button">
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
</ContextMenu>

View File

@@ -0,0 +1,22 @@
import { cn } from "#/utils/utils";
interface ContextMenuIconTextProps {
icon: React.ComponentType<{ className?: string }>;
text: string;
className?: string;
iconClassName?: string;
}
export function ContextMenuIconText({
icon: Icon,
text,
className,
iconClassName,
}: ContextMenuIconTextProps) {
return (
<div className={cn("flex items-center gap-3 px-1", className)}>
<Icon className={cn("w-4 h-4 shrink-0", iconClassName)} />
{text}
</div>
);
}

View File

@@ -19,7 +19,7 @@ export function ContextMenuListItem({
onClick={onClick}
disabled={isDisabled}
className={cn(
"text-sm px-4 py-2 w-full text-start hover:bg-white/10 first-of-type:rounded-t-md last-of-type:rounded-b-md",
"text-sm px-4 h-10 w-full text-start hover:bg-white/10 cursor-pointer",
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent text-nowrap",
)}
>

View File

@@ -18,7 +18,7 @@ export function ContextMenu({
<ul
data-testid={testId}
ref={ref}
className={cn("bg-tertiary rounded-md", className)}
className={cn("bg-tertiary rounded-md overflow-hidden", className)}
>
{children}
</ul>

View File

@@ -1,9 +1,20 @@
import {
Trash,
Power,
Pencil,
Download,
Wallet,
Wrench,
Bot,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { cn } from "#/utils/utils";
import { ContextMenu } from "../context-menu/context-menu";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuSeparator } from "../context-menu/context-menu-separator";
import { I18nKey } from "#/i18n/declaration";
import { ContextMenuIconText } from "../context-menu/context-menu-icon-text";
interface ConversationCardContextMenuProps {
onClose: () => void;
@@ -31,6 +42,12 @@ export function ConversationCardContextMenu({
const { t } = useTranslation();
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
const hasEdit = Boolean(onEdit);
const hasDownload = Boolean(onDownloadViaVSCode);
const hasTools = Boolean(onShowAgentTools || onShowMicroagents);
const hasInfo = Boolean(onDisplayCost);
const hasControl = Boolean(onStop || onDelete);
return (
<ContextMenu
ref={ref}
@@ -41,51 +58,84 @@ export function ConversationCardContextMenu({
position === "bottom" && "top-full",
)}
>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
{t(I18nKey.BUTTON$EDIT_TITLE)}
<ContextMenuIconText
icon={Pencil}
text={t(I18nKey.BUTTON$EDIT_TITLE)}
/>
</ContextMenuListItem>
)}
{hasEdit && (hasDownload || hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
)}
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
<ContextMenuIconText
icon={Download}
text={t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
/>
</ContextMenuListItem>
)}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
{t(I18nKey.BUTTON$DISPLAY_COST)}
</ContextMenuListItem>
{hasDownload && (hasTools || hasInfo || hasControl) && (
<ContextMenuSeparator />
)}
{onShowAgentTools && (
<ContextMenuListItem
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
<ContextMenuIconText
icon={Wrench}
text={t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
/>
</ContextMenuListItem>
)}
{onShowMicroagents && (
<ContextMenuListItem
testId="show-microagents-button"
onClick={onShowMicroagents}
>
{t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
<ContextMenuIconText
icon={Bot}
text={t(I18nKey.CONVERSATION$SHOW_MICROAGENTS)}
/>
</ContextMenuListItem>
)}
{hasTools && (hasInfo || hasControl) && <ContextMenuSeparator />}
{onDisplayCost && (
<ContextMenuListItem
testId="display-cost-button"
onClick={onDisplayCost}
>
<ContextMenuIconText
icon={Wallet}
text={t(I18nKey.BUTTON$DISPLAY_COST)}
/>
</ContextMenuListItem>
)}
{hasInfo && hasControl && <ContextMenuSeparator />}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
</ContextMenuListItem>
)}
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
<ContextMenuIconText icon={Trash} text={t(I18nKey.BUTTON$DELETE)} />
</ContextMenuListItem>
)}
</ContextMenu>

View File

@@ -1,10 +1,12 @@
import { useTranslation } from "react-i18next";
import { FaInfoCircle } from "react-icons/fa";
import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links";
import { useUserProviders } from "#/hooks/use-user-providers";
import { GitRepository } from "#/types/git";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface RepoConnectorProps {
onRepoSelection: (repo: GitRepository | null) => void;
@@ -23,7 +25,19 @@ export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
data-testid="repo-connector"
className="w-full flex flex-col gap-6"
>
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
<div className="flex items-center gap-2">
<h2 className="heading">{t("HOME$CONNECT_TO_REPOSITORY")}</h2>
<TooltipButton
testId="repo-connector-info"
tooltip={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
ariaLabel={t("HOME$CONNECT_TO_REPOSITORY_TOOLTIP")}
className="text-[#9099AC] hover:text-white"
placement="bottom"
tooltipClassName="max-w-[348px]"
>
<FaInfoCircle size={16} />
</TooltipButton>
</div>
{!providersAreSet && <ConnectToProviderMessage />}
{providersAreSet && (

View File

@@ -2,22 +2,15 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
import { Branch, GitRepository } from "#/types/git";
import { BrandButton } from "../settings/brand-button";
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
import { useDebounce } from "#/hooks/use-debounce";
import { sanitizeQuery } from "#/utils/sanitize-query";
import {
RepositoryDropdown,
RepositoryLoadingState,
RepositoryErrorState,
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "./repository-selection";
import { useUserProviders } from "#/hooks/use-user-providers";
import { Provider } from "#/types/settings";
import { GitProviderDropdown } from "../../common/git-provider-dropdown";
import { GitRepositoryDropdown } from "../../common/git-repository-dropdown";
import { GitBranchDropdown } from "../../common/git-branch-dropdown";
interface RepositorySelectionFormProps {
onRepoSelection: (repo: GitRepository | null) => void;
@@ -32,18 +25,11 @@ export function RepositorySelectionForm({
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
null,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = React.useRef<boolean>(false);
const {
data: repositories,
isLoading: isLoadingRepositories,
isError: isRepositoriesError,
} = useUserRepositories();
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const [selectedProvider, setSelectedProvider] =
React.useState<Provider | null>(null);
const { providers } = useUserProviders();
const { data: branches, isLoading: isLoadingBranches } =
useRepositoryBranches(selectedRepository?.full_name || null);
const {
mutate: createConversation,
isPending,
@@ -52,151 +38,108 @@ export function RepositorySelectionForm({
const isCreatingConversationElsewhere = useIsCreatingConversation();
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = React.useState("");
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
// Auto-select provider if there's only one
React.useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches &&
!branchManuallyClearedRef.current // Only auto-select if not manually cleared
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
if (providers.length === 1 && !selectedProvider) {
setSelectedProvider(providers[0]);
}
}, [branches, isLoadingBranches, selectedBranch]);
}, [providers, selectedProvider]);
// We check for isSuccess because the app might require time to render
// into the new conversation screen after the conversation is created.
const isCreatingConversation =
isPending || isSuccess || isCreatingConversationElsewhere;
const allRepositories = repositories?.concat(searchedRepos || []);
const repositoriesItems = allRepositories?.map((repo) => ({
key: repo.id,
label: decodeURIComponent(repo.full_name),
}));
// Check if repository has no branches (empty array after loading completes)
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
setSelectedRepository(selectedRepo || null);
setSelectedBranch(null); // Reset branch selection when repo changes
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
const handleProviderSelection = (provider: Provider | null) => {
setSelectedProvider(provider);
setSelectedRepository(null); // Reset repository selection when provider changes
setSelectedBranch(null); // Reset branch selection when provider changes
onRepoSelection(null); // Reset parent component's selected repo
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleRepoInputChange = (value: string) => {
if (value === "") {
setSelectedRepository(null);
setSelectedBranch(null);
onRepoSelection(null);
} else if (value.startsWith("https://")) {
const repoName = sanitizeQuery(value);
setSearchQuery(repoName);
const handleBranchSelection = (branchName: string | null) => {
const selectedBranchObj = branches?.find(
(branch) => branch.name === branchName,
);
if (selectedBranchObj) {
setSelectedBranch(selectedBranchObj);
}
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI based on the loading/error state
const renderRepositorySelector = () => {
if (isLoadingRepositories) {
return <RepositoryLoadingState />;
}
if (isRepositoriesError) {
return <RepositoryErrorState />;
// Render the provider dropdown
const renderProviderSelector = () => {
// Only render if there are multiple providers
if (providers.length <= 1) {
return null;
}
return (
<RepositoryDropdown
items={repositoriesItems || []}
onSelectionChange={handleRepoSelection}
onInputChange={handleRepoInputChange}
defaultFilter={(textValue, inputValue) => {
if (!inputValue) return true;
const repo = allRepositories?.find((r) => r.full_name === textValue);
if (!repo) return false;
const sanitizedInput = sanitizeQuery(inputValue);
return sanitizeQuery(textValue).includes(sanitizedInput);
}}
<GitProviderDropdown
providers={providers}
value={selectedProvider}
placeholder="Select Provider"
className="max-w-[500px]"
onChange={handleProviderSelection}
/>
);
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
/>
// Effect to auto-select main/master branch when branches are loaded
React.useEffect(() => {
if (branches?.length) {
// Look for main or master branch
const defaultBranch = branches.find(
(branch) => branch.name === "main" || branch.name === "master",
);
}
if (isLoadingBranches) {
return <BranchLoadingState />;
// If found, select it, otherwise select the first branch
setSelectedBranch(defaultBranch || branches[0]);
}
}, [branches]);
if (isBranchesError) {
return <BranchErrorState />;
}
// Render the repository selector using our new component
const renderRepositorySelector = () => {
const handleRepoSelection = (repository?: GitRepository) => {
if (repository) {
onRepoSelection(repository);
setSelectedRepository(repository);
} else {
setSelectedRepository(null);
setSelectedBranch(null);
}
};
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
<GitRepositoryDropdown
provider={selectedProvider || providers[0]}
value={selectedRepository?.id || null}
placeholder="Search repositories..."
disabled={!selectedProvider}
onChange={handleRepoSelection}
className="max-w-[500px]"
/>
);
};
// Render the branch selector
const renderBranchSelector = () => (
<GitBranchDropdown
repositoryName={selectedRepository?.full_name}
value={selectedBranch?.name || null}
placeholder="Select branch..."
className="max-w-[500px]"
disabled={!selectedRepository}
onChange={handleBranchSelection}
/>
);
return (
<div className="flex flex-col gap-4">
{renderProviderSelector()}
{renderRepositorySelector()}
{renderBranchSelector()}
<BrandButton
@@ -205,9 +148,10 @@ export function RepositorySelectionForm({
type="button"
isDisabled={
!selectedRepository ||
(!selectedBranch && !hasNoBranches) ||
isLoadingBranches ||
isCreatingConversation ||
isLoadingRepositories ||
isRepositoriesError
(providers.length > 1 && !selectedProvider)
}
onClick={() =>
createConversation(
@@ -215,7 +159,7 @@ export function RepositorySelectionForm({
repository: {
name: selectedRepository?.full_name || "",
gitProvider: selectedRepository?.git_provider || "github",
branch: selectedBranch?.name || "main",
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
},
},
{

View File

@@ -1,6 +1,3 @@
export { RepositoryDropdown } from "#/components/features/home/repository-selection/repository-dropdown";
export { RepositoryLoadingState } from "#/components/features/home/repository-selection/repository-loading-state";
export { RepositoryErrorState } from "#/components/features/home/repository-selection/repository-error-state";
export { BranchDropdown } from "#/components/features/home/repository-selection/branch-dropdown";
export { BranchLoadingState } from "#/components/features/home/repository-selection/branch-loading-state";
export { BranchErrorState } from "#/components/features/home/repository-selection/branch-error-state";

View File

@@ -1,33 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
export interface RepositoryDropdownProps {
items: { key: React.Key; label: string }[];
onSelectionChange: (key: React.Key | null) => void;
onInputChange: (value: string) => void;
defaultFilter?: (textValue: string, inputValue: string) => boolean;
}
export function RepositoryDropdown({
items,
onSelectionChange,
onInputChange,
defaultFilter,
}: RepositoryDropdownProps) {
const { t } = useTranslation();
return (
<SettingsDropdownInput
testId="repo-dropdown"
name="repo-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
items={items}
wrapperClassName="max-w-[500px]"
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
defaultFilter={defaultFilter}
/>
);
}

View File

@@ -1,14 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
export function RepositoryErrorState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_REPOSITORIES")}</span>
</div>
);
}

View File

@@ -1,16 +0,0 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
export function RepositoryLoadingState() {
const { t } = useTranslation();
return (
<div
data-testid="repo-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_REPOSITORIES")}</span>
</div>
);
}

View File

@@ -2,7 +2,6 @@ import { useMemo } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { Conversation } from "#/api/open-hands.types";
import {
@@ -38,22 +37,6 @@ export function MicroagentManagementMicroagentCard({
pr_number: prNumber,
} = conversation ?? {};
// Format the repository URL to point to the microagent file
const microagentFilePath = microagent
? `.openhands/microagents/${microagent.name}`
: "";
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = useMemo(() => {
if (microagent) {
return formatDateMMDDYYYY(new Date(microagent.created_at));
}
if (conversation) {
return formatDateMMDDYYYY(new Date(conversation.created_at));
}
return "";
}, [microagent, conversation]);
const hasPr = !!(prNumber && prNumber.length > 0);
// Helper function to get status text
@@ -131,12 +114,9 @@ export function MicroagentManagementMicroagentCard({
<div className="text-white text-[16px] font-semibold">{cardTitle}</div>
{!!microagent && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
{microagent.path}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
</div>
);

View File

@@ -5,6 +5,7 @@ import { Spinner } from "@heroui/react";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import { useUserProviders } from "#/hooks/use-user-providers";
import {
setPersonalRepositories,
setOrganizationRepositories,
@@ -22,15 +23,21 @@ export function MicroagentManagementSidebar({
}: MicroagentManagementSidebarProps) {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
const { providers } = useUserProviders();
const selectedProvider = providers.length > 0 ? providers[0] : null;
const { data: repositories, isLoading } =
useUserRepositories(selectedProvider);
useEffect(() => {
if (repositories) {
if (repositories?.pages) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
// Flatten all pages to get all repositories
const allRepositories = repositories.pages.flatMap((page) => page.data);
allRepositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {

View File

@@ -8,11 +8,12 @@ import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { cn, extractRepositoryInfo } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
import {
BranchDropdown,
BranchLoadingState,
@@ -51,13 +52,23 @@ export function MicroagentManagementUpsertMicroagentModal({
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
// Extract owner and repo from full_name for content API
const { owner, repo, filePath } = extractRepositoryInfo(
selectedRepository,
microagent,
);
// Fetch microagent content when updating
const { data: microagentContentData, isLoading: isLoadingContent } =
useRepositoryMicroagentContent(owner, repo, filePath, true);
// Populate form fields with existing microagent data when updating
useEffect(() => {
if (isUpdate && microagent) {
setQuery(microagent.content);
setTriggers(microagent.triggers || []);
if (isUpdate && microagentContentData) {
setQuery(microagentContentData.content);
setTriggers(microagentContentData.triggers || []);
}
}, [isUpdate, microagent]);
}, [isUpdate, microagentContentData]);
const {
data: branches,
@@ -294,10 +305,11 @@ export function MicroagentManagementUpsertMicroagentModal({
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
isBranchesError ||
(isUpdate && isLoadingContent) // Disable while loading content for updates
}
>
{isLoading || isLoadingBranches
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>

View File

@@ -1,3 +1,5 @@
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { useSelector } from "react-redux";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -7,8 +9,12 @@ import { ul, ol } from "../markdown/list";
import { paragraph } from "../markdown/paragraph";
import { anchor } from "../markdown/anchor";
import { RootState } from "#/store";
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
import { I18nKey } from "#/i18n/declaration";
import { extractRepositoryInfo } from "#/utils/utils";
export function MicroagentManagementViewMicroagentContent() {
const { t } = useTranslation();
const { selectedMicroagentItem } = useSelector(
(state: RootState) => state.microagentManagement,
);
@@ -19,55 +25,49 @@ export function MicroagentManagementViewMicroagentContent() {
const { microagent } = selectedMicroagentItem ?? {};
const transformMicroagentContent = (): string => {
if (!microagent) {
return "";
}
// Extract owner and repo from full_name (e.g., "owner/repo")
const { owner, repo, filePath } = extractRepositoryInfo(
selectedRepository,
microagent,
);
// If no triggers exist, return the content as-is
if (!microagent.triggers || microagent.triggers.length === 0) {
return microagent.content;
}
// Create the triggers frontmatter
const triggersFrontmatter = `
---
triggers:
${microagent.triggers.map((trigger) => ` - ${trigger}`).join("\n")}
---
`;
// Prepend the frontmatter to the content
return `
${triggersFrontmatter}
${microagent.content}
`;
};
// Fetch microagent content using the new API
const {
data: microagentData,
isLoading,
error,
} = useRepositoryMicroagentContent(owner, repo, filePath, true);
if (!microagent || !selectedRepository) {
return null;
}
// Transform the content to include triggers frontmatter if applicable
const transformedContent = transformMicroagentContent();
return (
<div className="w-full h-full p-6 bg-[#ffffff1a] rounded-2xl text-white text-sm">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{transformedContent}
</Markdown>
{isLoading && (
<div className="flex items-center justify-center w-full h-full">
<Spinner size="lg" data-testid="loading-microagent-content-spinner" />
</div>
)}
{error && (
<div className="flex items-center justify-center w-full h-full">
{t(I18nKey.MICROAGENT_MANAGEMENT$ERROR_LOADING_MICROAGENT_CONTENT)}
</div>
)}
{microagentData && !isLoading && !error && (
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{microagentData.content}
</Markdown>
)}
</div>
);
}

View File

@@ -12,16 +12,21 @@ export function ConfigureGitHubRepositoriesAnchor({
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="py-9"
>
<BrandButton type="button" variant="secondary">
<div data-testid="configure-github-repositories-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
`https://github.com/apps/${slug}/installations/new`,
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.GITHUB$CONFIGURE_REPOS)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -6,16 +6,21 @@ export function InstallSlackAppAnchor() {
const { t } = useTranslation();
return (
<a
data-testid="install-slack-app-button"
href="https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope="
target="_blank"
rel="noreferrer noopener"
className="py-9"
>
<BrandButton type="button" variant="secondary">
<div data-testid="install-slack-app-button" className="py-9">
<BrandButton
type="button"
variant="primary"
className="w-55"
onClick={() =>
window.open(
"https://slack.com/oauth/v2/authorize?client_id=7477886716822.8729519890534&scope=app_mentions:read,channels:history,chat:write,groups:history,im:history,mpim:history,users:read&user_scope=",
"_blank",
"noreferrer noopener",
)
}
>
{t(I18nKey.SLACK$INSTALL_APP)}
</BrandButton>
</a>
</div>
);
}

View File

@@ -0,0 +1,440 @@
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { SettingsInput } from "#/components/features/settings/settings-input";
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
import { ModalBody } from "#/components/shared/modals/modal-body";
import {
BaseModalDescription,
BaseModalTitle,
} from "#/components/shared/modals/confirmation-modals/base-modal";
import { SettingsSwitch } from "#/components/features/settings/settings-switch";
import { useValidateIntegration } from "#/hooks/mutation/use-validate-integration";
interface ConfigureButtonProps {
onClick: () => void;
isDisabled: boolean;
text?: string;
"data-testid"?: string;
}
export function ConfigureButton({
onClick,
isDisabled,
text,
"data-testid": dataTestId,
}: ConfigureButtonProps) {
const { t } = useTranslation();
return (
<BrandButton
data-testid={dataTestId}
variant="primary"
onClick={onClick}
isDisabled={isDisabled}
type="button"
className="w-30 min-w-20"
>
{text || t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL)}
</BrandButton>
);
}
interface ConfigureModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => void;
onLink: (workspace: string) => void;
onUnlink?: () => void;
platformName: string;
platform: "jira" | "jira-dc" | "linear";
integrationData?: {
id: number;
keycloak_user_id: string;
status: string;
workspace?: {
id: number;
name: string;
status: string;
editable: boolean;
};
} | null;
}
export function ConfigureModal({
isOpen,
onClose,
onConfirm,
onLink,
onUnlink,
platformName,
platform,
integrationData,
}: ConfigureModalProps) {
const { t } = useTranslation();
const [workspace, setWorkspace] = useState("");
const [webhookSecret, setWebhookSecret] = useState("");
const [serviceAccountEmail, setServiceAccountEmail] = useState("");
const [serviceAccountApiKey, setServiceAccountApiKey] = useState("");
const [isActive, setIsActive] = useState(true);
const [showConfigurationFields, setShowConfigurationFields] = useState(false);
// Determine initial state based on integrationData
const existingWorkspace = integrationData?.workspace;
const isWorkspaceEditable = existingWorkspace?.editable ?? false;
// Set initial workspace value when modal opens
React.useEffect(() => {
if (isOpen && existingWorkspace) {
setWorkspace(existingWorkspace.name);
setShowConfigurationFields(isWorkspaceEditable);
} else if (isOpen && !existingWorkspace) {
setWorkspace("");
setShowConfigurationFields(false);
}
}, [isOpen, existingWorkspace, isWorkspaceEditable]);
// Validation states
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [webhookSecretError, setWebhookSecretError] = useState<string | null>(
null,
);
const [emailError, setEmailError] = useState<string | null>(null);
const [apiKeyError, setApiKeyError] = useState<string | null>(null);
const validateMutation = useValidateIntegration(platform, {
onSuccess: (data) => {
if (data.data.status === "active") {
// Validation successful, proceed with linking
onLink(workspace.trim());
} else {
// Show configuration fields for further setup
setShowConfigurationFields(true);
setIsActive(true);
}
},
onError: (error) => {
if (error.response?.status === 404) {
// Integration not found, show configuration fields
setShowConfigurationFields(true);
setIsActive(true);
} else {
// Other errors - still show configuration fields as fallback
setShowConfigurationFields(true);
setIsActive(true);
}
},
});
// Validation functions
const validateWorkspace = (value: string) => {
const isValid = /^[a-zA-Z0-9\-_.]*$/.test(value);
if (!isValid && value.length > 0) {
setWorkspaceError(
t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_VALIDATION_ERROR),
);
} else {
setWorkspaceError(null);
}
return isValid;
};
const validateWebhookSecret = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setWebhookSecretError(
t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_NAME_VALIDATION_ERROR),
);
} else {
setWebhookSecretError(null);
}
return !hasSpaces;
};
const validateEmail = (value: string) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValid = emailRegex.test(value) || value.length === 0;
if (!isValid && value.length > 0) {
setEmailError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_EMAIL_VALIDATION_ERROR),
);
} else {
setEmailError(null);
}
return isValid;
};
const validateApiKey = (value: string) => {
const hasSpaces = /\s/.test(value);
if (hasSpaces) {
setApiKeyError(
t(I18nKey.PROJECT_MANAGEMENT$SVC_ACC_API_KEY_VALIDATION_ERROR),
);
} else {
setApiKeyError(null);
}
return !hasSpaces;
};
// Input handlers with validation
const handleWorkspaceChange = (value: string) => {
setWorkspace(value);
validateWorkspace(value);
};
const handleWebhookSecretChange = (value: string) => {
setWebhookSecret(value);
validateWebhookSecret(value);
};
const handleEmailChange = (value: string) => {
setServiceAccountEmail(value);
validateEmail(value);
};
const handleApiKeyChange = (value: string) => {
setServiceAccountApiKey(value);
validateApiKey(value);
};
const handleClose = () => {
setWorkspace("");
setWebhookSecret("");
setServiceAccountEmail("");
setServiceAccountApiKey("");
setIsActive(false);
setShowConfigurationFields(false);
setWorkspaceError(null);
setWebhookSecretError(null);
setEmailError(null);
setApiKeyError(null);
onClose();
};
if (!isOpen) {
return null;
}
const handleConnect = () => {
if (showConfigurationFields) {
// Full configuration flow (either new configuration or editing existing)
onConfirm({
workspace,
webhookSecret,
serviceAccountEmail,
serviceAccountApiKey,
isActive,
});
} else if (!existingWorkspace) {
// First check the workspace with validation for new integrations
validateMutation.mutate(workspace.trim());
}
// For existing workspace that's not editable, no action needed
// This case shouldn't happen as the button should be hidden
};
const isConnectDisabled = showConfigurationFields
? !workspace.trim() ||
!webhookSecret.trim() ||
!serviceAccountEmail.trim() ||
!serviceAccountApiKey.trim() ||
workspaceError !== null ||
webhookSecretError !== null ||
emailError !== null ||
apiKeyError !== null ||
validateMutation.isPending
: !workspace.trim() ||
workspaceError !== null ||
validateMutation.isPending;
return (
<ModalBackdrop onClose={handleClose}>
<ModalBody className="items-start border border-tertiary w-96">
<BaseModalTitle
title={
showConfigurationFields
? t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_TITLE, {
platform: platformName,
})
: t(I18nKey.PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE)
}
/>
<BaseModalDescription>
{showConfigurationFields ? (
<Trans
i18nKey={I18nKey.PROJECT_MANAGEMENT$CONFIGURE_MODAL_DESCRIPTION}
components={{
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
Check the document for more information
</a>
),
b: <b />,
}}
/>
) : (
<p className="mt-4">
<Trans
i18nKey={
I18nKey.PROJECT_MANAGEMENT$IMPORTANT_WORKSPACE_INTEGRATION
}
components={{
b: <b />,
a: (
<a
href="https://docs.all-hands.dev/usage/cloud/openhands-cloud"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 underline"
>
Check the document for more information
</a>
),
}}
/>
</p>
)}
</BaseModalDescription>
<div className="w-full flex flex-col gap-4 mt-4">
<div>
<div className="flex gap-2 items-end">
<div className="flex-1">
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER,
)}
value={workspace}
onChange={handleWorkspaceChange}
className="w-full"
type="text"
pattern="^[a-zA-Z0-9\-_.]*$"
isDisabled={!!existingWorkspace}
/>
</div>
{existingWorkspace && onUnlink && (
<BrandButton
variant="secondary"
onClick={onUnlink}
data-testid="unlink-button"
type="button"
className="mb-0"
>
{t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)}
</BrandButton>
)}
</div>
{workspaceError && (
<p className="text-red-500 text-sm mt-2">{workspaceError}</p>
)}
</div>
{showConfigurationFields && (
<>
<div>
<SettingsInput
label={t(I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_LABEL)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$WEBHOOK_SECRET_PLACEHOLDER,
)}
value={webhookSecret}
onChange={handleWebhookSecretChange}
className="w-full"
type="password"
/>
{webhookSecretError && (
<p className="text-red-500 text-sm mt-2">
{webhookSecretError}
</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_EMAIL_PLACEHOLDER,
)}
value={serviceAccountEmail}
onChange={handleEmailChange}
className="w-full"
type="email"
/>
{emailError && (
<p className="text-red-500 text-sm mt-2">{emailError}</p>
)}
</div>
<div>
<SettingsInput
label={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_LABEL,
)}
placeholder={t(
I18nKey.PROJECT_MANAGEMENT$SERVICE_ACCOUNT_API_PLACEHOLDER,
)}
value={serviceAccountApiKey}
onChange={handleApiKeyChange}
className="w-full"
type="password"
/>
{apiKeyError && (
<p className="text-red-500 text-sm mt-2">{apiKeyError}</p>
)}
</div>
<div className="mt-4">
<SettingsSwitch
testId="active-toggle"
onToggle={setIsActive}
isToggled={isActive}
>
{t(I18nKey.PROJECT_MANAGEMENT$ACTIVE_TOGGLE_LABEL)}
</SettingsSwitch>
</div>
</>
)}
</div>
<div className="flex flex-col gap-2 w-full mt-4">
{/* Hide the connect/edit button if workspace exists but is not editable */}
{(!existingWorkspace || isWorkspaceEditable) && (
<BrandButton
variant="primary"
onClick={handleConnect}
data-testid="connect-button"
type="button"
className="w-full"
isDisabled={isConnectDisabled}
>
{(() => {
if (existingWorkspace && showConfigurationFields) {
return t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL);
}
return t(I18nKey.PROJECT_MANAGEMENT$CONNECT_BUTTON_LABEL);
})()}
</BrandButton>
)}
<BrandButton
variant="secondary"
onClick={handleClose}
data-testid="cancel-button"
type="button"
className="w-full"
>
{t(I18nKey.FEEDBACK$CANCEL_LABEL)}
</BrandButton>
</div>
</ModalBody>
</ModalBackdrop>
);
}

View File

@@ -0,0 +1,37 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { BrandButton } from "#/components/features/settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
interface IntegrationButtonProps {
isLoading: boolean;
isLinked: boolean;
onClick: () => void;
"data-testid"?: string;
}
export function IntegrationButton({
isLoading,
isLinked,
onClick,
"data-testid": dataTestId,
}: IntegrationButtonProps) {
const { t } = useTranslation();
return (
<BrandButton
data-testid={dataTestId}
variant={isLinked ? "secondary" : "primary"}
onClick={onClick}
isDisabled={isLoading}
type="button"
className="w-30 min-w-20"
>
{isLoading && t(I18nKey.SETTINGS$SAVING)}
{!isLoading &&
(isLinked
? t(I18nKey.PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL)
: t(I18nKey.PROJECT_MANAGEMENT$LINK_BUTTON_LABEL))}
</BrandButton>
);
}

View File

@@ -0,0 +1,110 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { useIntegrationStatus } from "#/hooks/query/use-integration-status";
import { useLinkIntegration } from "#/hooks/mutation/use-link-integration";
import { useUnlinkIntegration } from "#/hooks/mutation/use-unlink-integration";
import { useConfigureIntegration } from "#/hooks/mutation/use-configure-integration";
import { I18nKey } from "#/i18n/declaration";
import {
ConfigureButton,
ConfigureModal,
} from "#/components/features/settings/project-management/configure-modal";
interface IntegrationRowProps {
platform: "jira" | "jira-dc" | "linear";
platformName: string;
"data-testid"?: string;
}
export function IntegrationRow({
platform,
platformName,
"data-testid": dataTestId,
}: IntegrationRowProps) {
const [isConfigureModalOpen, setConfigureModalOpen] = React.useState(false);
const { t } = useTranslation();
const { data: integrationData, isLoading: isStatusLoading } =
useIntegrationStatus(platform);
const linkMutation = useLinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const unlinkMutation = useUnlinkIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const configureMutation = useConfigureIntegration(platform, {
onSettled: () => {
setConfigureModalOpen(false);
},
});
const handleConfigure = () => {
setConfigureModalOpen(true);
};
const handleLink = (workspace: string) => {
linkMutation.mutate(workspace);
};
const handleUnlink = () => {
unlinkMutation.mutate();
};
const handleConfigureConfirm = (data: {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}) => {
configureMutation.mutate(data);
};
const isLoading =
isStatusLoading ||
linkMutation.isPending ||
unlinkMutation.isPending ||
configureMutation.isPending;
// Determine if integration is active and workspace exists
const isIntegrationActive = integrationData?.status === "active";
const hasWorkspace = integrationData?.workspace;
// Determine button text based on integration state
const buttonText =
isIntegrationActive && hasWorkspace
? t(I18nKey.PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL)
: t(I18nKey.PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL);
return (
<div className="flex items-center justify-between" data-testid={dataTestId}>
<span className="font-medium">{platformName}</span>
<div className="flex items-center gap-6">
<ConfigureButton
onClick={handleConfigure}
isDisabled={isLoading}
text={buttonText}
data-testid={`${platform}-configure-button`}
/>
</div>
<ConfigureModal
isOpen={isConfigureModalOpen}
onClose={() => setConfigureModalOpen(false)}
onConfirm={handleConfigureConfirm}
onLink={handleLink}
onUnlink={handleUnlink}
platformName={platformName}
platform={platform}
integrationData={integrationData}
/>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { IntegrationRow } from "./integration-row";
import { useConfig } from "#/hooks/query/use-config";
export function ProjectManagementIntegration() {
const { t } = useTranslation();
const { data: config } = useConfig();
return (
<div className="flex flex-col gap-4 w-1/4">
<h3 className="text-xl font-medium text-white">
{t(I18nKey.PROJECT_MANAGEMENT$TITLE)}
</h3>
<div className="flex flex-col gap-4">
{config?.FEATURE_FLAGS?.ENABLE_JIRA && (
<IntegrationRow
platform="jira"
platformName="Jira Cloud"
data-testid="jira-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_JIRA_DC && (
<IntegrationRow
platform="jira-dc"
platformName="Jira Data Center"
data-testid="jira-dc-integration-row"
/>
)}
{config?.FEATURE_FLAGS?.ENABLE_LINEAR && (
<IntegrationRow
platform="linear"
platformName="Linear"
data-testid="linear-integration-row"
/>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { Autocomplete, AutocompleteItem } from "@heroui/react";
import { ReactNode } from "react";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { OptionalTag } from "./optional-tag";
import { cn } from "#/utils/utils";
@@ -44,6 +44,7 @@ export function SettingsDropdownInput({
defaultFilter,
}: SettingsDropdownInputProps) {
const { t } = useTranslation();
return (
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
{label && (

View File

@@ -1,6 +1,7 @@
import React from "react";
import { UserAvatar } from "./user-avatar";
import { AccountSettingsContextMenu } from "../context-menu/account-settings-context-menu";
import { useShouldShowUserFeatures } from "#/hooks/use-should-show-user-features";
interface UserActionsProps {
onLogout: () => void;
@@ -12,7 +13,11 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
const [accountContextMenuIsVisible, setAccountContextMenuIsVisible] =
React.useState(false);
// Use the shared hook to determine if user actions should be shown
const shouldShowUserActions = useShouldShowUserFeatures();
const toggleAccountMenu = () => {
// Always toggle the menu, even if user is undefined
setAccountContextMenuIsVisible((prev) => !prev);
};
@@ -25,6 +30,9 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
closeAccountMenu();
};
// Show the menu based on the new logic
const showMenu = accountContextMenuIsVisible && shouldShowUserActions;
return (
<div data-testid="user-actions" className="w-8 h-8 relative cursor-pointer">
<UserAvatar
@@ -33,7 +41,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
isLoading={isLoading}
/>
{accountContextMenuIsVisible && !!user && (
{showMenu && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}

View File

@@ -14,6 +14,7 @@ interface UserAvatarProps {
export function UserAvatar({ onClick, avatarUrl, isLoading }: UserAvatarProps) {
const { t } = useTranslation();
return (
<TooltipButton
testId="user-avatar"

View File

@@ -15,12 +15,14 @@ import { Provider } from "#/types/settings";
interface AuthModalProps {
githubAuthUrl: string | null;
appMode?: GetConfigResponse["APP_MODE"] | null;
authUrl?: GetConfigResponse["AUTH_URL"];
providersConfigured?: Provider[];
}
export function AuthModal({
githubAuthUrl,
appMode,
authUrl,
providersConfigured,
}: AuthModalProps) {
const { t } = useTranslation();
@@ -28,11 +30,19 @@ export function AuthModal({
const gitlabAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "gitlab",
authUrl,
});
const bitbucketAuthUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "bitbucket",
authUrl,
});
const enterpriseSsoUrl = useAuthUrl({
appMode: appMode || null,
identityProvider: "enterprise_sso",
authUrl,
});
const handleGitHubAuth = () => {
@@ -56,6 +66,13 @@ export function AuthModal({
}
};
const handleEnterpriseSsoAuth = () => {
if (enterpriseSsoUrl) {
// Always start the OIDC flow, let the backend handle TOS check
window.location.href = enterpriseSsoUrl;
}
};
// Only show buttons if providers are configured and include the specific provider
const showGithub =
providersConfigured &&
@@ -69,6 +86,10 @@ export function AuthModal({
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("bitbucket");
const showEnterpriseSso =
providersConfigured &&
providersConfigured.length > 0 &&
providersConfigured.includes("enterprise_sso");
// Check if no providers are configured
const noProvidersConfigured =
@@ -126,6 +147,17 @@ export function AuthModal({
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</BrandButton>
)}
{showEnterpriseSso && (
<BrandButton
type="button"
variant="primary"
onClick={handleEnterpriseSsoAuth}
className="w-full"
>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</BrandButton>
)}
</>
)}
</div>

View File

@@ -31,7 +31,7 @@ interface ConversationSubscriptionsContextType {
subscribeToConversation: (options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => void;
@@ -135,7 +135,7 @@ export function ConversationSubscriptionsProvider({
(options: {
conversationId: string;
sessionApiKey: string | null;
providersSet: ("github" | "gitlab" | "bitbucket")[];
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
baseUrl: string;
onEvent?: (event: unknown, conversationId: string) => void;
}) => {
@@ -226,6 +226,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("connect_error", (error) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
error,
@@ -233,6 +234,7 @@ export function ConversationSubscriptionsProvider({
});
socket.on("disconnect", (reason) => {
// eslint-disable-next-line no-console
console.warn(
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
reason,

View File

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

View File

@@ -0,0 +1,72 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
interface ConfigureIntegrationData {
workspace: string;
webhookSecret: string;
serviceAccountEmail: string;
serviceAccountApiKey: string;
isActive: boolean;
}
export function useConfigureIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (data: ConfigureIntegrationData) => {
const input = {
workspace_name: data.workspace,
webhook_secret: data.webhookSecret,
svc_acc_email: data.serviceAccountEmail,
svc_acc_api_key: data.serviceAccountApiKey,
is_active: data.isActive,
};
const response = await openHands.post(
`/integration/${platform}/workspaces`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Configuration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -0,0 +1,60 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useLinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: async (workspace: string) => {
const input = {
workspace_name: workspace,
};
const response = await openHands.post(
`/integration/${platform}/workspaces/link`,
input,
);
const { success, redirect, authorizationUrl } = response.data;
if (success) {
if (redirect) {
if (authorizationUrl) {
window.location.href = authorizationUrl;
} else {
throw new Error("Could not get authorization URL from the server.");
}
} else {
window.location.reload();
}
} else {
throw new Error("Link integration failed");
}
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -27,6 +27,10 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
settings.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
search_api_key: settings.SEARCH_API_KEY?.trim() || "",
max_budget_per_task: settings.MAX_BUDGET_PER_TASK,
git_user_name:
settings.GIT_USER_NAME?.trim() || DEFAULT_SETTINGS.GIT_USER_NAME,
git_user_email:
settings.GIT_USER_EMAIL?.trim() || DEFAULT_SETTINGS.GIT_USER_EMAIL,
};
await OpenHands.saveSettings(apiSettings);

View File

@@ -1,7 +1,10 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import {
BatchFeedbackData,
getFeedbackQueryKey,
} from "../query/use-batch-feedback";
type SubmitConversationFeedbackArgs = {
rating: number;
@@ -12,7 +15,6 @@ type SubmitConversationFeedbackArgs = {
export const useSubmitConversationFeedback = () => {
const { conversationId } = useConversationId();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: ({ rating, eventId, reason }: SubmitConversationFeedbackArgs) =>
@@ -22,18 +24,56 @@ export const useSubmitConversationFeedback = () => {
eventId,
reason,
),
onSuccess: (_, { eventId }) => {
// Invalidate the feedback existence query to trigger a refetch
onMutate: async ({ rating, eventId, reason }) => {
if (!eventId) return { previousFeedback: null };
// Get the query key for the feedback data
const queryKey = getFeedbackQueryKey(conversationId);
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey });
// Snapshot the previous value
const previousFeedback =
queryClient.getQueryData<Record<string, BatchFeedbackData>>(queryKey);
// Optimistically update the cache
queryClient.setQueryData<Record<string, BatchFeedbackData>>(
queryKey,
(old = {}) => {
const newData = { ...old };
newData[eventId.toString()] = {
exists: true,
rating,
reason,
metadata: { source: "likert-scale" },
};
return newData;
},
);
// Return a context object with the snapshotted value
return { previousFeedback };
},
onError: (error, { eventId }, context) => {
// Roll back to the previous value on error
if (context?.previousFeedback && eventId) {
queryClient.setQueryData(
getFeedbackQueryKey(conversationId),
context.previousFeedback,
);
}
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
// eslint-disable-next-line no-console
console.error(error);
},
onSettled: (_, __, { eventId }) => {
if (eventId) {
// Invalidate both the old and new query keys to ensure consistency
queryClient.invalidateQueries({
queryKey: ["feedback", "exists", conversationId, eventId],
queryKey: getFeedbackQueryKey(conversationId),
});
}
},
onError: (error) => {
// Log error but don't show toast - user will just see the UI stay in unsubmitted state
// eslint-disable-next-line no-console
console.error(t("FEEDBACK$FAILED_TO_SUBMIT"), error);
},
});
};

View File

@@ -0,0 +1,38 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useUnlinkIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSettled,
}: {
onSettled: () => void;
},
) {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: () =>
openHands.post(`/integration/${platform}/workspaces/unlink`),
onSuccess: () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
queryClient.invalidateQueries({
queryKey: ["integration-status", platform],
});
},
onError: (error) => {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
},
onSettled,
});
}

View File

@@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { useTranslation } from "react-i18next";
import { openHands } from "#/api/open-hands-axios";
import { I18nKey } from "#/i18n/declaration";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
export function useValidateIntegration(
platform: "jira" | "jira-dc" | "linear",
{
onSuccess,
onError,
}: {
onSuccess: (data: any) => void;
onError: (error: any) => void;
},
) {
const { t } = useTranslation();
return useMutation({
mutationFn: (workspace?: string) => {
const workspaceParam = workspace ? `/${workspace}` : "";
return openHands.get(
`/integration/${platform}/workspaces/validate${workspaceParam}`,
);
},
onSuccess,
onError: (error) => {
if (axios.isAxiosError(error) && error.response?.status === 404) {
onError(error);
} else {
const errorMessage = retrieveAxiosErrorMessage(error);
displayErrorToast(
errorMessage ||
t(I18nKey.PROJECT_MANAGEMENT$VALIDATE_INTEGRATION_ERROR),
);
}
},
});
}

View File

@@ -0,0 +1,24 @@
import { useQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useIsAuthed } from "./use-is-authed";
import OpenHands from "#/api/open-hands";
import { useUserProviders } from "../use-user-providers";
import { Provider } from "#/types/settings";
import { shouldUseInstallationRepos } from "#/utils/utils";
export const useAppInstallations = (selectedProvider: Provider | null) => {
const { data: config } = useConfig();
const { data: userIsAuthenticated } = useIsAuthed();
const { providers } = useUserProviders();
return useQuery({
queryKey: ["installations", providers || [], selectedProvider],
queryFn: () => OpenHands.getUserInstallationIds(selectedProvider!),
enabled:
userIsAuthenticated &&
!!selectedProvider &&
shouldUseInstallationRepos(selectedProvider, config?.APP_MODE),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
};

View File

@@ -0,0 +1,52 @@
import React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
export interface BatchFeedbackData {
exists: boolean;
rating?: number;
reason?: string;
metadata?: Record<string, unknown>;
}
// Query key factory to ensure consistency across hooks
export const getFeedbackQueryKey = (conversationId?: string) =>
["feedback", "data", conversationId] as const;
// Query key factory for individual feedback existence
export const getFeedbackExistsQueryKey = (
conversationId: string,
eventId: number,
) => ["feedback", "exists", conversationId, eventId] as const;
export const useBatchFeedback = () => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
const queryClient = useQueryClient();
const runtimeIsReady = useRuntimeIsReady();
const query = useQuery({
queryKey: getFeedbackQueryKey(conversationId),
queryFn: () => OpenHands.getBatchFeedback(conversationId!),
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
// Update individual feedback cache entries when batch data changes
React.useEffect(() => {
if (query.data && conversationId) {
Object.entries(query.data).forEach(([eventId, feedback]) => {
queryClient.setQueryData(
getFeedbackExistsQueryKey(conversationId, parseInt(eventId, 10)),
feedback,
);
});
}
}, [query.data, conversationId, queryClient]);
return query;
};

View File

@@ -1,25 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useConfig } from "#/hooks/query/use-config";
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
export interface FeedbackData {
exists: boolean;
rating?: number;
reason?: string;
}
export type FeedbackData = BatchFeedbackData;
export const useFeedbackExists = (eventId?: number) => {
const queryClient = useQueryClient();
const { conversationId } = useConversationId();
const { data: config } = useConfig();
return useQuery<FeedbackData>({
queryKey: ["feedback", "exists", conversationId, eventId],
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
queryFn: () => {
if (!eventId) return { exists: false };
return OpenHands.checkFeedbackExists(conversationId, eventId);
// Try to get the data from the batch cache
const batchData = queryClient.getQueryData<
Record<string, BatchFeedbackData>
>(getFeedbackQueryKey(conversationId));
return batchData?.[eventId.toString()] ?? { exists: false };
},
enabled: !!eventId && config?.APP_MODE === "saas",
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});

View File

@@ -1,17 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { useUserProviders } from "../use-user-providers";
import { useConfig } from "./use-config";
import { useIsAuthed } from "#/hooks/query/use-is-authed";
export const useGetSecrets = () => {
const { data: config } = useConfig();
const { providers } = useUserProviders();
const { data: isAuthed } = useIsAuthed();
const isOss = config?.APP_MODE === "oss";
return useQuery({
queryKey: ["secrets"],
queryFn: SecretsService.getSecrets,
enabled: isOss || providers.length > 0,
enabled: isOss || isAuthed, // Enable regardless of providers
});
};

View File

@@ -0,0 +1,130 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { useConfig } from "./use-config";
import { useUserProviders } from "../use-user-providers";
import { useAppInstallations } from "./use-app-installations";
import { GitRepository } from "../../types/git";
import { Provider } from "../../types/settings";
import OpenHands from "#/api/open-hands";
import { shouldUseInstallationRepos } from "#/utils/utils";
interface UseGitRepositoriesOptions {
provider: Provider | null;
pageSize?: number;
enabled?: boolean;
}
interface UserRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
}
interface InstallationRepositoriesResponse {
data: GitRepository[];
nextPage: number | null;
installationIndex: number | null;
}
export function useGitRepositories(options: UseGitRepositoriesOptions) {
const { provider, pageSize = 30, enabled = true } = options;
const { providers } = useUserProviders();
const { data: config } = useConfig();
const { data: installations } = useAppInstallations(provider);
const useInstallationRepos = provider
? shouldUseInstallationRepos(provider, config?.APP_MODE)
: false;
const repos = useInfiniteQuery<
UserRepositoriesResponse | InstallationRepositoriesResponse
>({
queryKey: [
"repositories",
providers || [],
provider,
useInstallationRepos,
pageSize,
...(useInstallationRepos ? [installations || []] : []),
],
queryFn: async ({ pageParam }) => {
if (!provider) {
throw new Error("Provider is required");
}
if (useInstallationRepos) {
const { repoPage, installationIndex } = pageParam as {
installationIndex: number | null;
repoPage: number | null;
};
if (!installations) {
throw new Error("Missing installation list");
}
return OpenHands.retrieveInstallationRepositories(
provider,
installationIndex || 0,
installations,
repoPage || 1,
pageSize,
);
}
return OpenHands.retrieveUserGitRepositories(
provider,
pageParam as number,
pageSize,
);
},
getNextPageParam: (lastPage) => {
if (useInstallationRepos) {
const installationPage = lastPage as InstallationRepositoriesResponse;
if (installationPage.nextPage) {
return {
installationIndex: installationPage.installationIndex,
repoPage: installationPage.nextPage,
};
}
if (installationPage.installationIndex !== null) {
return {
installationIndex: installationPage.installationIndex,
repoPage: 1,
};
}
return null;
}
const userPage = lastPage as UserRepositoriesResponse;
return userPage.nextPage;
},
initialPageParam: useInstallationRepos
? { installationIndex: 0, repoPage: 1 }
: 1,
enabled:
enabled &&
(providers || []).length > 0 &&
!!provider &&
(!useInstallationRepos ||
(Array.isArray(installations) && installations.length > 0)),
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
refetchOnWindowFocus: false,
});
const onLoadMore = () => {
if (repos.hasNextPage && !repos.isFetchingNextPage) {
repos.fetchNextPage();
}
};
return {
data: repos.data,
isLoading: repos.isLoading,
isError: repos.isError,
hasNextPage: repos.hasNextPage,
isFetchingNextPage: repos.isFetchingNextPage,
fetchNextPage: repos.fetchNextPage,
onLoadMore,
};
}

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