mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
54 Commits
sdk/minima
...
refactor-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2193cf649c | ||
|
|
9d6afa09b6 | ||
|
|
c648b6f74f | ||
|
|
c0fa41da65 | ||
|
|
6eb32e9ae4 | ||
|
|
6a544d4274 | ||
|
|
4aada82b75 | ||
|
|
ab2da611f5 | ||
|
|
e47bcf31e4 | ||
|
|
83b9262379 | ||
|
|
edc95141f7 | ||
|
|
5b35203253 | ||
|
|
7e3eabe777 | ||
|
|
23713bfe8c | ||
|
|
81829289ab | ||
|
|
9709431874 | ||
|
|
0e9906f41e | ||
|
|
9ac9a47207 | ||
|
|
75653e805a | ||
|
|
9630b536cd | ||
|
|
6f5c8186b8 | ||
|
|
36e0d8d3da | ||
|
|
e68abf8d75 | ||
|
|
93ef1b0cda | ||
|
|
77b5c6b161 | ||
|
|
57aa7d5c12 | ||
|
|
50391ecdf3 | ||
|
|
672650d3d9 | ||
|
|
9afedea170 | ||
|
|
c0bb84dfa2 | ||
|
|
18b5139237 | ||
|
|
4849369ede | ||
|
|
b082ccc0fb | ||
|
|
b0007076c0 | ||
|
|
4a4f213f57 | ||
|
|
f9099fe6db | ||
|
|
8f46a0a7a3 | ||
|
|
55d204ae1b | ||
|
|
4d7cd228da | ||
|
|
a3f92df4b3 | ||
|
|
e41f8f5215 | ||
|
|
6448f5a681 | ||
|
|
5fcc648d5f | ||
|
|
c9d96038c1 | ||
|
|
408af4e012 | ||
|
|
d9ac2faff6 | ||
|
|
64383a66e2 | ||
|
|
7fbcb29499 | ||
|
|
e7aae1495c | ||
|
|
d33f27d141 | ||
|
|
d08851859b | ||
|
|
7f4d311294 | ||
|
|
049f058ed1 | ||
|
|
bb6cf5a816 |
1
.github/workflows/e2e-tests.yml
vendored
1
.github/workflows/e2e-tests.yml
vendored
@@ -187,6 +187,7 @@ jobs:
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
|
||||
4
.github/workflows/ghcr-build.yml
vendored
4
.github/workflows/ghcr-build.yml
vendored
@@ -225,7 +225,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
@@ -284,7 +284,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 7 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 40 days with no activity. Remove the stale label or leave a comment, otherwise it will be closed in 10 days.'
|
||||
days-before-stale: 40
|
||||
exempt-issue-labels: 'roadmap'
|
||||
exempt-issue-labels: roadmap,backlog
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
|
||||
@@ -130,7 +130,6 @@ If you want to modify the OpenHands source code, check out [Development.md](http
|
||||
Having issues? The [Troubleshooting Guide](https://docs.all-hands.dev/usage/troubleshooting) can help.
|
||||
|
||||
## 📖 Documentation
|
||||
<a href="https://deepwiki.com/All-Hands-AI/OpenHands"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki" title="Autogenerated Documentation by DeepWiki"></a>
|
||||
|
||||
To learn more about the project, and for tips on using OpenHands,
|
||||
check out our [documentation](https://docs.all-hands.dev/usage/getting-started).
|
||||
|
||||
@@ -58,34 +58,34 @@ 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 --gid $OPENHANDS_USER_ID app
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID openhands
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG openhands openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
RUN chown -R openhands:app /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
USER openhands
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH='/app'
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:app
|
||||
RUN find /app \! -group app -exec chgrp app {} +
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
|
||||
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ else
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
usermod -aG app enduser
|
||||
usermod -aG openhands enduser
|
||||
# get the user group of /var/run/docker.sock and set openhands to that group
|
||||
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
echo "Docker socket group id: $DOCKER_SOCKET_GID"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Jira Data Center Integration (Beta)
|
||||
title: Jira Data Center Integration (Coming soon...)
|
||||
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.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Jira Cloud Integration
|
||||
title: Jira Cloud Integration (Coming soon...)
|
||||
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.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Linear Integration
|
||||
title: Linear Integration (Coming soon...)
|
||||
description: Complete guide for setting up Linear integration with OpenHands Cloud, including service account creation, API key generation, webhook configuration, and workspace integration setup.
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Project Management Tool Integrations
|
||||
title: Project Management Tool Integrations (Coming soon...)
|
||||
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.
|
||||
---
|
||||
|
||||
@@ -18,9 +18,9 @@ Integration requires two levels of setup:
|
||||
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)
|
||||
- [Jira Cloud Integration (Coming soon...)](./jira-integration.md)
|
||||
- [Jira Data Center Integration (Coming soon...)](./jira-dc-integration.md)
|
||||
- [Linear Integration (Coming soon...)](./linear-integration.md)
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -87,19 +87,13 @@ source ~/.bashrc # or source ~/.zshrc
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
3. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
# If using uvx (recommended)
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
|
||||
The first time you run the CLI, it will take you through configuring the required LLM
|
||||
|
||||
@@ -45,6 +45,13 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
1. [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install).
|
||||
2. Run `wsl --version` in powershell and confirm `Default Version: 2`.
|
||||
|
||||
**Ubuntu (Linux Distribution)**
|
||||
|
||||
1. Install Ubuntu: `wsl --install -d Ubuntu` in PowerShell as Administrator.
|
||||
2. Restart computer when prompted.
|
||||
3. Open Ubuntu from Start menu to complete setup.
|
||||
4. Verify installation: `wsl --list` should show Ubuntu.
|
||||
|
||||
**Docker Desktop**
|
||||
|
||||
1. [Install Docker Desktop on Windows](https://docs.docker.com/desktop/setup/install/windows-install).
|
||||
@@ -53,7 +60,7 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
- Resources > WSL Integration: `Enable integration with my default WSL distro` is enabled.
|
||||
|
||||
<Note>
|
||||
The docker command below to start the app must be run inside the WSL terminal.
|
||||
The docker command below to start the app must be run inside the WSL terminal. Use `wsl -d Ubuntu` in PowerShell or search "Ubuntu" in the Start menu to access the Ubuntu terminal.
|
||||
</Note>
|
||||
|
||||
**Alternative: Windows without WSL**
|
||||
|
||||
@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
|
||||
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
|
||||
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
|
||||
directory. and it's called `openhands`.
|
||||
directory, and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -38,6 +38,16 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
### On Linux, Getting ConnectTimeout Error
|
||||
|
||||
**Description**
|
||||
|
||||
When running on Linux, you might run into the error `ERROR:root:<class 'httpx.ConnectTimeout'>: timed out`.
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Add the `--network host` to the docker run command.
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
**Description**
|
||||
|
||||
@@ -13,6 +13,7 @@ N_RUNS=${4:-1}
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
export ITERATIVE_EVAL_MODE=false
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset
|
||||
|
||||
209
evaluation/utils/scripts/aggregate_token_usage.py
Executable file
209
evaluation/utils/scripts/aggregate_token_usage.py
Executable file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to aggregate token usage metrics from LLM completion files.
|
||||
|
||||
Usage:
|
||||
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
|
||||
|
||||
Arguments:
|
||||
directory_path: Path to the directory containing completion files
|
||||
--input-cost: Cost per input token (default: 0.0)
|
||||
--output-cost: Cost per output token (default: 0.0)
|
||||
--cached-cost: Cost per cached token (default: 0.0)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def aggregate_token_usage(
|
||||
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
|
||||
):
|
||||
"""
|
||||
Aggregate token usage metrics from all JSON completion files in the directory.
|
||||
|
||||
Args:
|
||||
directory_path (str): Path to directory containing completion files
|
||||
input_cost (float): Cost per input token
|
||||
output_cost (float): Cost per output token
|
||||
cached_cost (float): Cost per cached token
|
||||
"""
|
||||
|
||||
# Initialize counters
|
||||
totals = {
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'cached_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'files_processed': 0,
|
||||
'files_with_errors': 0,
|
||||
'cost': 0,
|
||||
}
|
||||
|
||||
# Find all JSON files recursively
|
||||
json_files = list(Path(directory_path).rglob('*.json'))
|
||||
|
||||
print(f'Found {len(json_files)} JSON files to process...')
|
||||
|
||||
for json_file in json_files:
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Look for usage data in response or fncall_response
|
||||
usage_data = None
|
||||
if (
|
||||
'response' in data
|
||||
and isinstance(data['response'], dict)
|
||||
and 'usage' in data['response']
|
||||
):
|
||||
usage_data = data['response']['usage']
|
||||
elif (
|
||||
'fncall_response' in data
|
||||
and isinstance(data['fncall_response'], dict)
|
||||
and 'usage' in data['fncall_response']
|
||||
):
|
||||
usage_data = data['fncall_response']['usage']
|
||||
|
||||
if usage_data:
|
||||
# Extract token counts
|
||||
completion_tokens = usage_data.get('completion_tokens', 0)
|
||||
prompt_tokens = usage_data.get('prompt_tokens', 0)
|
||||
cached_tokens = usage_data.get('cached_tokens', 0)
|
||||
|
||||
# Handle cases where cached_tokens might be in prompt_tokens_details
|
||||
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
|
||||
details = usage_data['prompt_tokens_details']
|
||||
if isinstance(details, dict) and 'cached_tokens' in details:
|
||||
cached_tokens = details.get('cached_tokens', 0) or 0
|
||||
|
||||
# Calculate non-cached input tokens
|
||||
non_cached_input = prompt_tokens - cached_tokens
|
||||
|
||||
# Update totals
|
||||
totals['input_tokens'] += non_cached_input
|
||||
totals['output_tokens'] += completion_tokens
|
||||
totals['cached_tokens'] += cached_tokens
|
||||
totals['total_tokens'] += prompt_tokens + completion_tokens
|
||||
|
||||
if 'cost' in data:
|
||||
totals['cost'] += data['cost']
|
||||
totals['files_processed'] += 1
|
||||
|
||||
# Progress indicator
|
||||
if totals['files_processed'] % 1000 == 0:
|
||||
print(f'Processed {totals["files_processed"]} files...')
|
||||
|
||||
except Exception as e:
|
||||
totals['files_with_errors'] += 1
|
||||
if totals['files_with_errors'] <= 5: # Only show first 5 errors
|
||||
print(f'Error processing {json_file}: {e}')
|
||||
|
||||
# Calculate costs
|
||||
input_cost_total = totals['input_tokens'] * input_cost
|
||||
output_cost_total = totals['output_tokens'] * output_cost
|
||||
cached_cost_total = totals['cached_tokens'] * cached_cost
|
||||
total_cost = input_cost_total + output_cost_total + cached_cost_total
|
||||
|
||||
# Print results
|
||||
print('\n' + '=' * 60)
|
||||
print('TOKEN USAGE AGGREGATION RESULTS')
|
||||
print('=' * 60)
|
||||
print(f'Files processed: {totals["files_processed"]:,}')
|
||||
print(f'Files with errors: {totals["files_with_errors"]:,}')
|
||||
print()
|
||||
print('TOKEN COUNTS:')
|
||||
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
|
||||
print(f' Output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Cached tokens: {totals["cached_tokens"]:,}')
|
||||
print(f' Total tokens: {totals["total_tokens"]:,}')
|
||||
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
|
||||
print()
|
||||
|
||||
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
|
||||
print('COST CALCULATED BASED ON PROVIDED RATE:')
|
||||
print(
|
||||
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
|
||||
)
|
||||
print(f' Total cost: ${total_cost:.6f}')
|
||||
print()
|
||||
|
||||
print('SUMMARY:')
|
||||
print(
|
||||
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
|
||||
)
|
||||
print(f' Total output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Grand total tokens: {totals["total_tokens"]:,}')
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Aggregate token usage metrics from LLM completion files',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python aggregate_token_usage.py /path/to/completions
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory_path', help='Path to directory containing completion files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--input-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per input token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per output token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cached-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per cached token (default: 0.0)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate directory path
|
||||
if not os.path.exists(args.directory_path):
|
||||
print(f"Error: Directory '{args.directory_path}' does not exist.")
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(args.directory_path):
|
||||
print(f"Error: '{args.directory_path}' is not a directory.")
|
||||
return 1
|
||||
|
||||
# Run aggregation
|
||||
try:
|
||||
aggregate_token_usage(
|
||||
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
|
||||
)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'Error during aggregation: {e}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -54,12 +54,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "All-Hands-AI/OpenHands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,16 +101,15 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then interact with the repository dropdown
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -134,23 +135,23 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeDisabled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -161,7 +162,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
expect(launchButton).toBeEnabled();
|
||||
@@ -224,6 +226,19 @@ describe("RepoConnector", () => {
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
conversation_id: "mock-conversation-id",
|
||||
title: "Test Conversation",
|
||||
selected_repository: "user/repo1",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
last_updated_at: "2023-01-01T00:00:00Z",
|
||||
created_at: "2023-01-01T00:00:00Z",
|
||||
status: "STARTING",
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -244,23 +259,23 @@ describe("RepoConnector", () => {
|
||||
expect(createConversationSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -271,7 +286,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -288,6 +304,8 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockImplementation(() => new Promise(() => {})); // Never resolves to keep loading state
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
@@ -298,10 +316,10 @@ describe("RepoConnector", () => {
|
||||
});
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
@@ -309,16 +327,16 @@ describe("RepoConnector", () => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const repoDropdown = await waitFor(() =>
|
||||
screen.getByTestId("repo-dropdown"),
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
const repoInput = within(repoDropdown).getByRole("combobox");
|
||||
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// Wait for the options to be loaded and displayed
|
||||
@@ -329,7 +347,8 @@ describe("RepoConnector", () => {
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
|
||||
await userEvent.click(launchButton);
|
||||
@@ -358,7 +377,7 @@ describe("RepoConnector", () => {
|
||||
const goToSettingsButton = await screen.findByTestId(
|
||||
"navigate-to-settings-button",
|
||||
);
|
||||
const dropdown = screen.queryByTestId("repo-dropdown");
|
||||
const dropdown = screen.queryByTestId("git-repo-dropdown");
|
||||
const launchButton = screen.queryByTestId("repo-launch-button");
|
||||
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ describe("RepositorySelectionForm", () => {
|
||||
});
|
||||
|
||||
renderForm();
|
||||
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message when repository fetch fails", async () => {
|
||||
@@ -168,10 +168,10 @@ describe("RepositorySelectionForm", () => {
|
||||
renderForm();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId("repo-dropdown-error"),
|
||||
await screen.findByTestId("dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
screen.getByText("Failed to load data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -231,11 +231,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
@@ -270,11 +266,7 @@ describe("RepositorySelectionForm", () => {
|
||||
|
||||
renderForm();
|
||||
|
||||
const dropdown = await screen.findByTestId("repo-dropdown");
|
||||
const input = dropdown.querySelector(
|
||||
'input[type="text"]',
|
||||
) as HTMLInputElement;
|
||||
expect(input).toBeInTheDocument();
|
||||
const input = await screen.findByTestId("git-repo-dropdown");
|
||||
|
||||
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
|
||||
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,34 +37,27 @@ const selectRepository = async (repoName: string) => {
|
||||
|
||||
// First select the provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByText("Select Provider"),
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("Github"));
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then select the repository
|
||||
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
|
||||
const repoInput = within(dropdown).getByRole("combobox");
|
||||
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
|
||||
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 dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
|
||||
});
|
||||
const options = screen.getAllByText(repoName);
|
||||
const dropdownOption = options.find(
|
||||
(el) => el.getAttribute("role") === "option",
|
||||
);
|
||||
await userEvent.click(dropdownOption!);
|
||||
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
|
||||
await userEvent.click(within(dropdownMenu).getByText(repoName));
|
||||
|
||||
// Wait for the branch to be auto-selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("main")).toBeInTheDocument();
|
||||
const branchInput = screen.getByTestId("git-branch-dropdown-input");
|
||||
expect(branchInput).toHaveValue("main");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -85,12 +78,14 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
|
||||
full_name: "octocat/hello-world",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "octocat/earth",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
main_branch: "main",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -140,10 +135,10 @@ describe("HomeScreen", () => {
|
||||
await screen.findAllByTestId("task-launch-button");
|
||||
|
||||
// Mock the repository branches API call
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
|
||||
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
]);
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
|
||||
2347
frontend/package-lock.json
generated
2347
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,50 +11,50 @@
|
||||
"@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.8.0",
|
||||
"@react-router/serve": "^7.8.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@react-router/node": "^7.8.2",
|
||||
"@react-router/serve": "^7.8.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.0",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@stripe/react-stripe-js": "^3.9.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.3",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.11.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "^9.0.10",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"i18next": "^25.3.6",
|
||||
"i18next": "^25.4.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.29",
|
||||
"jose": "^6.0.12",
|
||||
"lucide-react": "^0.539.0",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.1.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.260.1",
|
||||
"posthog-js": "^1.261.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.6.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.0",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-router": "^7.8.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.1.1",
|
||||
"vite": "^7.1.3",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -88,17 +88,17 @@
|
||||
"@babel/traverse": "^7.28.3",
|
||||
"@babel/types": "^7.28.2",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@react-router/dev": "^7.8.0",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@react-router/dev": "^7.8.2",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.83.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.7.0",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.2.0",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -117,16 +117,16 @@
|
||||
"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",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^26.1.0",
|
||||
"lint-staged": "^16.1.4",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.4.0",
|
||||
"stripe": "^18.5.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.0.2"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,12 @@ import {
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
|
||||
import { GitUser, GitRepository, Branch } from "#/types/git";
|
||||
import {
|
||||
GitUser,
|
||||
GitRepository,
|
||||
PaginatedBranchesResponse,
|
||||
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";
|
||||
@@ -567,14 +572,38 @@ class OpenHands {
|
||||
};
|
||||
}
|
||||
|
||||
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
|
||||
static async getRepositoryBranches(
|
||||
repository: string,
|
||||
page: number = 1,
|
||||
perPage: number = 30,
|
||||
): Promise<PaginatedBranchesResponse> {
|
||||
const { data } = await openHands.get<PaginatedBranchesResponse>(
|
||||
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}&page=${page}&per_page=${perPage}`,
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async searchRepositoryBranches(
|
||||
repository: string,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
): Promise<Branch[]> {
|
||||
const { data } = await openHands.get<Branch[]>(
|
||||
`/api/user/search/branches`,
|
||||
{
|
||||
params: {
|
||||
repository,
|
||||
query,
|
||||
per_page: perPage,
|
||||
selected_provider: selectedProvider,
|
||||
},
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available microagents associated with a conversation
|
||||
* @param conversationId The ID of the conversation
|
||||
@@ -726,6 +755,27 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Provider } from "../../types/settings";
|
||||
import { useGitRepositories } from "../../hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "../../hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "../../hooks/use-debounce";
|
||||
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;
|
||||
}
|
||||
|
||||
export function GitRepositoryDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
errorMessage,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const debouncedSearchInput = useDebounce(searchInput, 300);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedSearchInput.startsWith("https://")) {
|
||||
const match = debouncedSearchInput.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedSearchInput;
|
||||
}
|
||||
return debouncedSearchInput;
|
||||
}, [debouncedSearchInput]);
|
||||
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search query for processed input (handles URLs)
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
const allOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
data?.pages
|
||||
? data.pages.flatMap((page) =>
|
||||
page.data.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
})),
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const searchOptions: AsyncSelectOption[] = useMemo(
|
||||
() =>
|
||||
searchData
|
||||
? searchData.map((repo) => ({
|
||||
value: repo.id,
|
||||
label: repo.full_name,
|
||||
}))
|
||||
: [],
|
||||
[searchData],
|
||||
);
|
||||
|
||||
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 results
|
||||
const searchOption = searchOptions.find((opt) => opt.value === value);
|
||||
if (searchOption) return searchOption;
|
||||
|
||||
return null;
|
||||
}, [allOptions, searchOptions, value]);
|
||||
|
||||
const loadOptions = useCallback(
|
||||
async (inputValue: string): Promise<AsyncSelectOption[]> => {
|
||||
// Update search input to trigger debounced search
|
||||
setSearchInput(inputValue);
|
||||
|
||||
// If empty input, show all loaded options
|
||||
if (!inputValue.trim()) {
|
||||
return allOptions;
|
||||
}
|
||||
|
||||
// For very short inputs, do local filtering
|
||||
if (inputValue.length < 2) {
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle URL inputs by performing direct search
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
try {
|
||||
// Perform direct search for URL-based inputs
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
return repositories.map((repo) => ({
|
||||
value: repo.full_name,
|
||||
label: repo.full_name,
|
||||
data: repo,
|
||||
}));
|
||||
} catch (error) {
|
||||
// Fall back to local filtering if search fails
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(repoName.toLowerCase()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For regular text inputs, use hook-based search results if available
|
||||
if (searchOptions.length > 0 && processedSearchInput === inputValue) {
|
||||
return searchOptions;
|
||||
}
|
||||
|
||||
// Fallback to local filtering while search is loading
|
||||
return allOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
},
|
||||
[allOptions, searchOptions, processedSearchInput, provider],
|
||||
);
|
||||
|
||||
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 = searchData?.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 || isFetchingNextPage || isSearchLoading}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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
|
||||
}),
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { OpenHandsSourceType } from "#/types/core/base";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
@@ -16,6 +17,7 @@ interface ChatMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -66,17 +68,35 @@ export function ChatMessage({
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
{actions?.map((action, index) =>
|
||||
action.tooltip ? (
|
||||
<TooltipButton
|
||||
key={index}
|
||||
tooltip={action.tooltip}
|
||||
ariaLabel={action.tooltip}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
|
||||
@@ -72,6 +72,9 @@ const getRecallObservationContent = (event: RecallObservation): string => {
|
||||
if (event.extras.repo_instructions) {
|
||||
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
|
||||
}
|
||||
if (event.extras.conversation_instructions) {
|
||||
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
|
||||
}
|
||||
if (event.extras.additional_agent_instructions) {
|
||||
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ interface EventMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
@@ -24,6 +25,17 @@ import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
interface MessagesProps {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
@@ -31,8 +43,11 @@ interface MessagesProps {
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
const {
|
||||
createConversationAndSubscribe,
|
||||
isPending,
|
||||
unsubscribeFromConversation,
|
||||
} = useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
@@ -48,6 +63,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -93,20 +110,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
|
||||
const handleMicroagentEvent = React.useCallback(
|
||||
(socketEvent: unknown, microagentConversationId: string) => {
|
||||
// Handle error events
|
||||
const isErrorEvent = (
|
||||
evt: unknown,
|
||||
): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
@@ -119,7 +122,11 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent)
|
||||
) {
|
||||
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
|
||||
// Handle completion states
|
||||
if (
|
||||
socketEvent.extras.agent_state === AgentState.FINISHED ||
|
||||
socketEvent.extras.agent_state === AgentState.AWAITING_USER_INPUT
|
||||
) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
@@ -127,6 +134,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
|
||||
unsubscribeFromConversation(microagentConversationId);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
@@ -147,9 +156,27 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
unsubscribeFromConversation(microagentConversationId);
|
||||
} else {
|
||||
// For any other event, transition from WAITING to CREATING if still waiting
|
||||
setMicroagentStatuses((prev) => {
|
||||
const currentStatus = prev.find(
|
||||
(entry) => entry.conversationId === microagentConversationId,
|
||||
)?.status;
|
||||
|
||||
if (currentStatus === MicroagentStatus.WAITING) {
|
||||
return prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.CREATING }
|
||||
: statusEntry,
|
||||
);
|
||||
}
|
||||
return prev; // No change needed
|
||||
});
|
||||
}
|
||||
},
|
||||
[setMicroagentStatuses],
|
||||
[setMicroagentStatuses, unsubscribeFromConversation],
|
||||
);
|
||||
|
||||
const handleLaunchMicroagent = (
|
||||
@@ -178,13 +205,13 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
},
|
||||
onSuccessCallback: (newConversationId: string) => {
|
||||
setShowLaunchMicroagentModal(false);
|
||||
// Update status with conversation ID
|
||||
// Update status with conversation ID - start with WAITING
|
||||
setMicroagentStatuses((prev) => [
|
||||
...prev.filter((status) => status.eventId !== selectedEventId),
|
||||
{
|
||||
eventId: selectedEventId,
|
||||
conversationId: newConversationId,
|
||||
status: MicroagentStatus.CREATING,
|
||||
status: MicroagentStatus.WAITING,
|
||||
},
|
||||
]);
|
||||
},
|
||||
@@ -219,6 +246,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
@@ -76,6 +76,10 @@ export function LaunchMicroagentModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
|
||||
{t("MICROAGENT$DEFINITION")}
|
||||
</span>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -19,6 +19,8 @@ export function MicroagentStatusIndicator({
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.WAITING:
|
||||
return t("MICROAGENT$STATUS_WAITING");
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
@@ -35,6 +37,8 @@ export function MicroagentStatusIndicator({
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.WAITING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
|
||||
@@ -10,6 +10,11 @@ interface ConversationCreatedToastProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ConversationStartingToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
@@ -37,6 +42,33 @@ function ConversationCreatedToast({
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationStartingToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationStartingToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$CONVERSATION_STARTING")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
@@ -78,10 +110,18 @@ function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Check if the error message is a translation key
|
||||
const displayMessage =
|
||||
errorMessage === "MICROAGENT$UNKNOWN_ERROR"
|
||||
? t(errorMessage)
|
||||
: errorMessage;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<div>{displayMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -136,3 +176,18 @@ export const renderConversationErroredToast = (
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationStartingToast = (conversationId: string) =>
|
||||
toast(
|
||||
(toastInstance) => (
|
||||
<ConversationStartingToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(toastInstance.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `starting-${conversationId}`,
|
||||
duration: 10000, // Show for 10 seconds or until dismissed
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,9 +23,9 @@ export function ConfirmStopModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start border border-tertiary">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_PAUSE)} />
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
|
||||
description={t(I18nKey.CONVERSATION$PAUSE_WARNING)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ConversationCardContextMenu({
|
||||
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$PAUSE)} />
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import ArchivedIcon from "./state-indicators/archived.svg?react";
|
||||
import ErrorIcon from "./state-indicators/error.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import StartingIcon from "./state-indicators/starting.svg?react";
|
||||
import StoppedIcon from "./state-indicators/stopped.svg?react";
|
||||
@@ -9,6 +11,8 @@ const CONVERSATION_STATUS_INDICATORS: Record<ConversationStatus, SVGIcon> = {
|
||||
STOPPED: StoppedIcon,
|
||||
RUNNING: RunningIcon,
|
||||
STARTING: StartingIcon,
|
||||
ARCHIVED: ArchivedIcon,
|
||||
ERROR: ErrorIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#A7A9AC"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 7h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1 0 1.43-.98 2.63-2.31 2.98l1.46 1.46C20.88 15.61 22 13.95 22 12c0-2.76-2.24-5-5-5zm-1 4h-2.19l2 2H16zM2 4.27l3.11 3.11C3.29 8.12 2 9.91 2 12c0 2.76 2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1 0-1.59 1.21-2.9 2.76-3.07L8.73 11H8v2h2.73L13 15.27V17h1.73l4.01 4L20 19.74 3.27 3 2 4.27z"/><path d="M0 24V0" fill="none"/></svg>
|
||||
|
After Width: | Height: | Size: 512 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e7000b"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -0,0 +1,86 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
export interface BranchDropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredBranches: Branch[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: Branch | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function BranchDropdownMenu({
|
||||
isOpen,
|
||||
filteredBranches,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: BranchDropdownMenuProps) {
|
||||
const renderItem = (
|
||||
branch: Branch,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Branch | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<Branch> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={branch.name}
|
||||
item={branch}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.name === branch.name}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(branchItem) => branchItem.name}
|
||||
getItemKey={(branchItem) => branchItem.name}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<li className="px-3 py-2">
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No branches found"
|
||||
emptyMessage="No branches available"
|
||||
testId="git-branch-dropdown-empty"
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-branch-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useBranchData } from "#/hooks/query/use-branch-data";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
|
||||
export interface GitBranchDropdownProps {
|
||||
repository: string | null;
|
||||
provider: Provider;
|
||||
selectedBranch: Branch | null;
|
||||
onBranchSelect: (branch: Branch | null) => void;
|
||||
defaultBranch?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitBranchDropdown({
|
||||
repository,
|
||||
provider,
|
||||
selectedBranch,
|
||||
onBranchSelect,
|
||||
defaultBranch,
|
||||
placeholder = "Select branch...",
|
||||
disabled = false,
|
||||
className,
|
||||
}: GitBranchDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [userManuallyCleared, setUserManuallyCleared] = useState(false);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input (debounced and filtered)
|
||||
const processedSearchInput = useMemo(
|
||||
() =>
|
||||
debouncedInputValue.trim().length > 0 ? debouncedInputValue.trim() : "",
|
||||
[debouncedInputValue],
|
||||
);
|
||||
|
||||
// Use the new branch data hook with default branch prioritization
|
||||
const {
|
||||
branches: filteredBranches,
|
||||
isLoading,
|
||||
isError,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
isSearchLoading,
|
||||
} = useBranchData(
|
||||
repository,
|
||||
provider,
|
||||
defaultBranch || null,
|
||||
processedSearchInput,
|
||||
inputValue,
|
||||
selectedBranch,
|
||||
);
|
||||
|
||||
const error = isError ? new Error("Failed to load branches") : null;
|
||||
|
||||
// Handle clear
|
||||
const handleClear = useCallback(() => {
|
||||
setInputValue("");
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(true); // Mark that user manually cleared the branch
|
||||
}, [onBranchSelect]);
|
||||
|
||||
// Handle branch selection
|
||||
const handleBranchSelect = useCallback(
|
||||
(branch: Branch | null) => {
|
||||
onBranchSelect(branch);
|
||||
setInputValue("");
|
||||
},
|
||||
[onBranchSelect],
|
||||
);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
if (newInputValue !== undefined) {
|
||||
setInputValue(newInputValue);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle menu scroll for infinite loading
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
if (
|
||||
scrollHeight - scrollTop <= clientHeight * 1.5 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
// Downshift configuration
|
||||
const {
|
||||
isOpen,
|
||||
selectedItem,
|
||||
highlightedIndex,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
getToggleButtonProps,
|
||||
} = useCombobox({
|
||||
items: filteredBranches,
|
||||
selectedItem: selectedBranch,
|
||||
itemToString: (item) => item?.name || "",
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleBranchSelect(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Reset branch selection when repository changes
|
||||
useEffect(() => {
|
||||
if (repository) {
|
||||
onBranchSelect(null);
|
||||
setUserManuallyCleared(false); // Reset the manual clear flag when repository changes
|
||||
}
|
||||
}, [repository, onBranchSelect]);
|
||||
|
||||
// Auto-select default branch when branches are loaded and no branch is selected
|
||||
// But only if the user hasn't manually cleared the branch
|
||||
useEffect(() => {
|
||||
if (
|
||||
repository &&
|
||||
defaultBranch &&
|
||||
!selectedBranch &&
|
||||
!userManuallyCleared && // Don't auto-select if user manually cleared
|
||||
filteredBranches.length > 0 &&
|
||||
!isLoading
|
||||
) {
|
||||
const defaultBranchObj = filteredBranches.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
if (defaultBranchObj) {
|
||||
onBranchSelect(defaultBranchObj);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
repository,
|
||||
defaultBranch,
|
||||
selectedBranch,
|
||||
userManuallyCleared,
|
||||
filteredBranches,
|
||||
onBranchSelect,
|
||||
isLoading,
|
||||
]);
|
||||
|
||||
// Reset input when repository changes
|
||||
useEffect(() => {
|
||||
setInputValue("");
|
||||
}, [repository]);
|
||||
|
||||
// Initialize input value when selectedBranch changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedBranch && !isOpen && inputValue !== selectedBranch.name) {
|
||||
setInputValue(selectedBranch.name);
|
||||
} else if (!selectedBranch && !isOpen && inputValue) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedBranch, isOpen, inputValue]);
|
||||
|
||||
const isLoadingState = isLoading || isSearchLoading || isFetchingNextPage;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled: disabled || !repository,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-branch-dropdown-input"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedBranch && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled || !repository}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && <LoadingSpinner hasSelection={!!selectedBranch} />}
|
||||
</div>
|
||||
|
||||
<BranchDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredBranches={filteredBranches}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
export { BranchDropdownMenu } from "./branch-dropdown-menu";
|
||||
export type { GitBranchDropdownProps } from "./git-branch-dropdown";
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu } from "../shared/generic-dropdown-menu";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { EmptyState } from "../shared/empty-state";
|
||||
|
||||
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 [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] = useState<Provider | null>(
|
||||
value || null,
|
||||
);
|
||||
|
||||
// Format provider names for display
|
||||
const formatProviderName = (provider: Provider): string => {
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return "GitHub";
|
||||
case "gitlab":
|
||||
return "GitLab";
|
||||
case "bitbucket":
|
||||
return "Bitbucket";
|
||||
case "enterprise_sso":
|
||||
return "Enterprise SSO";
|
||||
default:
|
||||
// Fallback for any future provider types
|
||||
return (
|
||||
(provider as string).charAt(0).toUpperCase() +
|
||||
(provider as string).slice(1)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter providers based on input value
|
||||
const filteredProviders = useMemo(() => {
|
||||
// If we have a selected provider and the input matches it exactly, show all providers
|
||||
if (
|
||||
localSelectedItem &&
|
||||
inputValue === formatProviderName(localSelectedItem)
|
||||
) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// If no input value, show all providers
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return providers;
|
||||
}
|
||||
|
||||
// Filter providers based on input
|
||||
return providers.filter((provider) =>
|
||||
formatProviderName(provider)
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase()),
|
||||
);
|
||||
}, [providers, inputValue, localSelectedItem]);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredProviders,
|
||||
itemToString: (item) => (item ? formatProviderName(item) : ""),
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
setLocalSelectedItem(newSelectedItem || null);
|
||||
onChange?.(newSelectedItem || null);
|
||||
},
|
||||
onInputValueChange: ({ inputValue: newInputValue }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync with external value prop
|
||||
useEffect(() => {
|
||||
if (value !== localSelectedItem) {
|
||||
setLocalSelectedItem(value || null);
|
||||
}
|
||||
}, [value, localSelectedItem]);
|
||||
|
||||
// Update input value when selection changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedItem && !isOpen) {
|
||||
setInputValue(formatProviderName(selectedItem));
|
||||
} else if (!selectedItem) {
|
||||
setInputValue("");
|
||||
}
|
||||
}, [selectedItem, isOpen]);
|
||||
|
||||
const renderItem = (
|
||||
item: Provider,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: Provider | null,
|
||||
currentGetItemProps: any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={item}
|
||||
item={item}
|
||||
index={index}
|
||||
isHighlighted={index === currentHighlightedIndex}
|
||||
isSelected={item === currentSelectedItem}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={formatProviderName}
|
||||
getItemKey={(provider) => provider}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState
|
||||
inputValue={currentInputValue}
|
||||
searchMessage="No providers found"
|
||||
emptyMessage="No providers available"
|
||||
testId="git-provider-dropdown-empty"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
readOnly: true, // Make it non-searchable like the original
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10 cursor-pointer", // Space for toggle button and pointer cursor
|
||||
),
|
||||
})}
|
||||
data-testid="git-provider-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSpinner hasSelection={!!selectedItem} />}
|
||||
</div>
|
||||
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredProviders}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={!!errorMessage} message={errorMessage} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
export type { GitProviderDropdownProps } from "./git-provider-dropdown";
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { DropdownItem } from "../shared/dropdown-item";
|
||||
import { GenericDropdownMenu, EmptyState } from "../shared";
|
||||
|
||||
interface DropdownMenuProps {
|
||||
isOpen: boolean;
|
||||
filteredRepositories: GitRepository[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: GitRepository | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef: React.RefObject<HTMLUListElement | null>;
|
||||
}
|
||||
|
||||
export function DropdownMenu({
|
||||
isOpen,
|
||||
filteredRepositories,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
}: DropdownMenuProps) {
|
||||
const renderItem = (
|
||||
repository: GitRepository,
|
||||
index: number,
|
||||
currentHighlightedIndex: number,
|
||||
currentSelectedItem: GitRepository | null,
|
||||
currentGetItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<GitRepository> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => (
|
||||
<DropdownItem
|
||||
key={repository.id}
|
||||
item={repository}
|
||||
index={index}
|
||||
isHighlighted={currentHighlightedIndex === index}
|
||||
isSelected={currentSelectedItem?.id === repository.id}
|
||||
getItemProps={currentGetItemProps}
|
||||
getDisplayText={(repo) => repo.full_name}
|
||||
getItemKey={(repo) => repo.id.toString()}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderEmptyState = (currentInputValue: string) => (
|
||||
<EmptyState inputValue={currentInputValue} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div data-testid="git-repo-dropdown-menu">
|
||||
<GenericDropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredItems={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={onScroll}
|
||||
menuRef={menuRef}
|
||||
renderItem={renderItem}
|
||||
renderEmptyState={renderEmptyState}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, {
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { useCombobox } from "downshift";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { LoadingSpinner } from "../shared/loading-spinner";
|
||||
import { ClearButton } from "../shared/clear-button";
|
||||
import { ToggleButton } from "../shared/toggle-button";
|
||||
import { ErrorMessage } from "../shared/error-message";
|
||||
import { useUrlSearch } from "./use-url-search";
|
||||
import { useRepositoryData } from "./use-repository-data";
|
||||
import { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
export interface GitRepoDropdownProps {
|
||||
provider: Provider;
|
||||
value?: string | null;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onChange?: (repository?: GitRepository) => void;
|
||||
}
|
||||
|
||||
export function GitRepoDropdown({
|
||||
provider,
|
||||
value,
|
||||
placeholder = "Search repositories...",
|
||||
className,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: GitRepoDropdownProps) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [localSelectedItem, setLocalSelectedItem] =
|
||||
useState<GitRepository | null>(null);
|
||||
const debouncedInputValue = useDebounce(inputValue, 300);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
// Process search input to handle URLs
|
||||
const processedSearchInput = useMemo(() => {
|
||||
if (debouncedInputValue.startsWith("https://")) {
|
||||
const match = debouncedInputValue.match(
|
||||
/https:\/\/[^/]+\/([^/]+\/[^/]+)/,
|
||||
);
|
||||
return match ? match[1] : debouncedInputValue;
|
||||
}
|
||||
return debouncedInputValue;
|
||||
}, [debouncedInputValue]);
|
||||
|
||||
// URL search functionality
|
||||
const { urlSearchResults, isUrlSearchLoading } = useUrlSearch(
|
||||
inputValue,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Repository data management
|
||||
const {
|
||||
repositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
} = useRepositoryData(
|
||||
provider,
|
||||
disabled,
|
||||
processedSearchInput,
|
||||
urlSearchResults,
|
||||
inputValue,
|
||||
value,
|
||||
);
|
||||
|
||||
// Filter repositories based on input value
|
||||
const filteredRepositories = useMemo(() => {
|
||||
// If we have URL search results, show them directly (no filtering needed)
|
||||
if (urlSearchResults.length > 0) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If we have a selected repository and the input matches it exactly, show all repositories
|
||||
if (selectedRepository && inputValue === selectedRepository.full_name) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// If no input value, show all repositories
|
||||
if (!inputValue || !inputValue.trim()) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
// For URL inputs, use the processed search input for filtering
|
||||
const filterText = inputValue.startsWith("https://")
|
||||
? processedSearchInput
|
||||
: inputValue;
|
||||
|
||||
return repositories.filter((repo) =>
|
||||
repo.full_name.toLowerCase().includes(filterText.toLowerCase()),
|
||||
);
|
||||
}, [
|
||||
repositories,
|
||||
inputValue,
|
||||
selectedRepository,
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
]);
|
||||
|
||||
// Handle selection
|
||||
const handleSelectionChange = useCallback(
|
||||
(selectedItem: GitRepository | null) => {
|
||||
setLocalSelectedItem(selectedItem);
|
||||
onChange?.(selectedItem || undefined);
|
||||
// Update input value to show selected item
|
||||
if (selectedItem) {
|
||||
setInputValue(selectedItem.full_name);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle clear selection
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalSelectedItem(null);
|
||||
handleSelectionChange(null);
|
||||
setInputValue("");
|
||||
}, [handleSelectionChange]);
|
||||
|
||||
// Handle input value change
|
||||
const handleInputValueChange = useCallback(
|
||||
({ inputValue: newInputValue }: { inputValue?: string }) => {
|
||||
setInputValue(newInputValue || "");
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle scroll to bottom for pagination
|
||||
const handleMenuScroll = useCallback(
|
||||
(event: React.UIEvent<HTMLUListElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
|
||||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 10;
|
||||
|
||||
if (isNearBottom && hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
[hasNextPage, isFetchingNextPage, fetchNextPage],
|
||||
);
|
||||
|
||||
const {
|
||||
isOpen,
|
||||
getToggleButtonProps,
|
||||
getMenuProps,
|
||||
getInputProps,
|
||||
highlightedIndex,
|
||||
getItemProps,
|
||||
selectedItem,
|
||||
} = useCombobox({
|
||||
items: filteredRepositories,
|
||||
itemToString: (item) => item?.full_name || "",
|
||||
selectedItem: localSelectedItem,
|
||||
onSelectedItemChange: ({ selectedItem: newSelectedItem }) => {
|
||||
handleSelectionChange(newSelectedItem);
|
||||
},
|
||||
onInputValueChange: handleInputValueChange,
|
||||
inputValue,
|
||||
});
|
||||
|
||||
// Sync localSelectedItem with external value prop
|
||||
useEffect(() => {
|
||||
if (selectedRepository) {
|
||||
setLocalSelectedItem(selectedRepository);
|
||||
} else if (value === null) {
|
||||
setLocalSelectedItem(null);
|
||||
}
|
||||
}, [selectedRepository, value]);
|
||||
|
||||
// Initialize input value when selectedRepository changes (but not when user is typing)
|
||||
useEffect(() => {
|
||||
if (selectedRepository && !isOpen) {
|
||||
setInputValue(selectedRepository.full_name);
|
||||
}
|
||||
}, [selectedRepository, isOpen]);
|
||||
|
||||
const isLoadingState =
|
||||
isLoading || isSearchLoading || isFetchingNextPage || isUrlSearchLoading;
|
||||
|
||||
return (
|
||||
<div className={cn("relative", className)}>
|
||||
<div className="relative">
|
||||
<input
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getInputProps({
|
||||
disabled,
|
||||
placeholder,
|
||||
className: cn(
|
||||
"w-full px-3 py-2 border border-[#717888] rounded-sm shadow-sm min-h-[2.5rem]",
|
||||
"bg-[#454545] text-[#ECEDEE] placeholder:text-[#B7BDC2] placeholder:italic",
|
||||
"focus:outline-none focus:ring-1 focus:ring-[#717888] focus:border-[#717888]",
|
||||
"disabled:bg-[#363636] disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"pr-10", // Space for toggle button
|
||||
),
|
||||
})}
|
||||
data-testid="git-repo-dropdown"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-1/2 transform -translate-y-1/2 flex items-center gap-1">
|
||||
{selectedRepository && (
|
||||
<ClearButton disabled={disabled} onClear={handleClear} />
|
||||
)}
|
||||
|
||||
<ToggleButton
|
||||
isOpen={isOpen}
|
||||
disabled={disabled}
|
||||
getToggleButtonProps={getToggleButtonProps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoadingState && (
|
||||
<LoadingSpinner hasSelection={!!selectedRepository} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DropdownMenu
|
||||
isOpen={isOpen}
|
||||
filteredRepositories={filteredRepositories}
|
||||
inputValue={inputValue}
|
||||
highlightedIndex={highlightedIndex}
|
||||
selectedItem={selectedItem}
|
||||
getMenuProps={getMenuProps}
|
||||
getItemProps={getItemProps}
|
||||
onScroll={handleMenuScroll}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
|
||||
<ErrorMessage isError={isError} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Main component
|
||||
export { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
export type { GitRepoDropdownProps } from "./git-repo-dropdown";
|
||||
|
||||
// Repository-specific UI Components
|
||||
export { DropdownMenu } from "./dropdown-menu";
|
||||
|
||||
// Repository-specific Custom Hooks
|
||||
export { useUrlSearch } from "./use-url-search";
|
||||
export { useRepositoryData } from "./use-repository-data";
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
|
||||
export function useRepositoryData(
|
||||
provider: Provider,
|
||||
disabled: boolean,
|
||||
processedSearchInput: string,
|
||||
urlSearchResults: GitRepository[],
|
||||
inputValue: string,
|
||||
value?: string | null,
|
||||
) {
|
||||
// Fetch user repositories with pagination
|
||||
const {
|
||||
data: repoData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useGitRepositories({
|
||||
provider,
|
||||
enabled: !disabled,
|
||||
});
|
||||
|
||||
// Search repositories when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } =
|
||||
useSearchRepositories(processedSearchInput, provider);
|
||||
|
||||
// Combine all repositories from paginated data
|
||||
const allRepositories = useMemo(
|
||||
() => repoData?.pages?.flatMap((page) => page.data) || [],
|
||||
[repoData],
|
||||
);
|
||||
|
||||
// Find selected repository from all possible sources
|
||||
const selectedRepository = useMemo(() => {
|
||||
if (!value) return null;
|
||||
|
||||
// Search in all possible repository sources
|
||||
const allPossibleRepos = [
|
||||
...allRepositories,
|
||||
...urlSearchResults,
|
||||
...(searchData || []),
|
||||
];
|
||||
|
||||
return allPossibleRepos.find((repo) => repo.id === value) || null;
|
||||
}, [allRepositories, urlSearchResults, searchData, value]);
|
||||
|
||||
// Get repositories to display (URL search, regular search, or all repos)
|
||||
const repositories = useMemo(() => {
|
||||
// Prioritize URL search results when available
|
||||
if (urlSearchResults.length > 0) {
|
||||
return urlSearchResults;
|
||||
}
|
||||
|
||||
// Don't use search results if input exactly matches selected repository
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedRepository && inputValue === selectedRepository.full_name);
|
||||
|
||||
if (shouldUseSearch) {
|
||||
return searchData;
|
||||
}
|
||||
return allRepositories;
|
||||
}, [
|
||||
urlSearchResults,
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
inputValue,
|
||||
]);
|
||||
|
||||
// Auto-load more repositories when there aren't enough items to create a scrollable dropdown
|
||||
// This is particularly important for SaaS mode with installations that might have very few repos
|
||||
useEffect(() => {
|
||||
const shouldAutoLoad =
|
||||
!disabled &&
|
||||
!isLoading &&
|
||||
!isFetchingNextPage &&
|
||||
!isSearchLoading &&
|
||||
hasNextPage &&
|
||||
!processedSearchInput && // Not during search (use all repos, not search results)
|
||||
urlSearchResults.length === 0 &&
|
||||
repositories.length > 0 && // Have some repositories loaded
|
||||
repositories.length < 10; // But not enough to create a scrollable dropdown
|
||||
|
||||
if (shouldAutoLoad) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [
|
||||
disabled,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isSearchLoading,
|
||||
hasNextPage,
|
||||
processedSearchInput,
|
||||
urlSearchResults.length,
|
||||
repositories.length,
|
||||
fetchNextPage,
|
||||
]);
|
||||
|
||||
return {
|
||||
repositories,
|
||||
allRepositories,
|
||||
selectedRepository,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export function useUrlSearch(inputValue: string, provider: Provider) {
|
||||
const [urlSearchResults, setUrlSearchResults] = useState<GitRepository[]>([]);
|
||||
const [isUrlSearchLoading, setIsUrlSearchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleUrlSearch = async () => {
|
||||
if (inputValue.startsWith("https://")) {
|
||||
const match = inputValue.match(/https:\/\/[^/]+\/([^/]+\/[^/]+)/);
|
||||
if (match) {
|
||||
const repoName = match[1];
|
||||
|
||||
setIsUrlSearchLoading(true);
|
||||
try {
|
||||
const repositories = await OpenHands.searchGitRepositories(
|
||||
repoName,
|
||||
3,
|
||||
provider,
|
||||
);
|
||||
|
||||
setUrlSearchResults(repositories);
|
||||
} catch (error) {
|
||||
setUrlSearchResults([]);
|
||||
} finally {
|
||||
setIsUrlSearchLoading(false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setUrlSearchResults([]);
|
||||
}
|
||||
};
|
||||
|
||||
handleUrlSearch();
|
||||
}, [inputValue, provider]);
|
||||
|
||||
return { urlSearchResults, isUrlSearchLoading };
|
||||
}
|
||||
@@ -2,15 +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 { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
// Removed useRepositoryBranches import - GitBranchDropdown manages its own data
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { Branch, GitRepository } from "#/types/git";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
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";
|
||||
import { GitProviderDropdown } from "./git-provider-dropdown";
|
||||
import { GitBranchDropdown } from "./git-branch-dropdown";
|
||||
import { GitRepoDropdown } from "./git-repo-dropdown";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
@@ -28,8 +28,6 @@ export function RepositorySelectionForm({
|
||||
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,
|
||||
@@ -50,8 +48,7 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Check if repository has no branches (empty array after loading completes)
|
||||
const hasNoBranches = !isLoadingBranches && branches && branches.length === 0;
|
||||
// Branch selection is now handled by GitBranchDropdown component
|
||||
|
||||
const handleProviderSelection = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
@@ -60,14 +57,9 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (branchName: string | null) => {
|
||||
const selectedBranchObj = branches?.find(
|
||||
(branch) => branch.name === branchName,
|
||||
);
|
||||
if (selectedBranchObj) {
|
||||
setSelectedBranch(selectedBranchObj);
|
||||
}
|
||||
};
|
||||
const handleBranchSelection = React.useCallback((branch: Branch | null) => {
|
||||
setSelectedBranch(branch);
|
||||
}, []);
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
@@ -87,19 +79,6 @@ export function RepositorySelectionForm({
|
||||
);
|
||||
};
|
||||
|
||||
// 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 found, select it, otherwise select the first branch
|
||||
setSelectedBranch(defaultBranch || branches[0]);
|
||||
}
|
||||
}, [branches]);
|
||||
|
||||
// Render the repository selector using our new component
|
||||
const renderRepositorySelector = () => {
|
||||
const handleRepoSelection = (repository?: GitRepository) => {
|
||||
@@ -107,13 +86,14 @@ export function RepositorySelectionForm({
|
||||
onRepoSelection(repository);
|
||||
setSelectedRepository(repository);
|
||||
} else {
|
||||
onRepoSelection(null); // Notify parent component that repo was cleared
|
||||
setSelectedRepository(null);
|
||||
setSelectedBranch(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<GitRepositoryDropdown
|
||||
<GitRepoDropdown
|
||||
provider={selectedProvider || providers[0]}
|
||||
value={selectedRepository?.id || null}
|
||||
placeholder="Search repositories..."
|
||||
@@ -125,16 +105,21 @@ export function RepositorySelectionForm({
|
||||
};
|
||||
|
||||
// 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}
|
||||
/>
|
||||
);
|
||||
const renderBranchSelector = () => {
|
||||
const defaultBranch = selectedRepository?.main_branch || null;
|
||||
return (
|
||||
<GitBranchDropdown
|
||||
repository={selectedRepository?.full_name || null}
|
||||
provider={selectedProvider || providers[0]}
|
||||
selectedBranch={selectedBranch}
|
||||
onBranchSelect={handleBranchSelection}
|
||||
defaultBranch={defaultBranch}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
disabled={!selectedRepository}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -148,8 +133,7 @@ export function RepositorySelectionForm({
|
||||
type="button"
|
||||
isDisabled={
|
||||
!selectedRepository ||
|
||||
(!selectedBranch && !hasNoBranches) ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isCreatingConversation ||
|
||||
(providers.length > 1 && !selectedProvider)
|
||||
}
|
||||
@@ -159,7 +143,7 @@ export function RepositorySelectionForm({
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || (hasNoBranches ? "" : "main"),
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ClearButtonProps {
|
||||
disabled: boolean;
|
||||
onClear: () => void;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ClearButton({
|
||||
disabled,
|
||||
onClear,
|
||||
testId = "dropdown-clear",
|
||||
}: ClearButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
)}
|
||||
type="button"
|
||||
aria-label="Clear selection"
|
||||
data-testid={testId}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface DropdownItemProps<T> {
|
||||
item: T;
|
||||
index: number;
|
||||
isHighlighted: boolean;
|
||||
isSelected: boolean;
|
||||
getItemProps: <Options>(options: any & Options) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getDisplayText: (item: T) => string;
|
||||
getItemKey: (item: T) => string;
|
||||
}
|
||||
|
||||
export function DropdownItem<T>({
|
||||
item,
|
||||
index,
|
||||
isHighlighted,
|
||||
isSelected,
|
||||
getItemProps,
|
||||
getDisplayText,
|
||||
getItemKey,
|
||||
}: DropdownItemProps<T>) {
|
||||
const itemProps = getItemProps({
|
||||
index,
|
||||
item,
|
||||
className: cn(
|
||||
"px-3 py-2 cursor-pointer text-sm rounded-lg mx-0.5 my-0.5",
|
||||
"text-[#ECEDEE] focus:outline-none",
|
||||
{
|
||||
"bg-[#24272E]": isHighlighted && !isSelected,
|
||||
"bg-[#C9B974] text-black": isSelected,
|
||||
"hover:bg-[#24272E]": !isSelected,
|
||||
"hover:bg-[#C9B974] hover:text-black": isSelected,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<li key={getItemKey(item)} {...itemProps}>
|
||||
<span className="font-medium">{getDisplayText(item)}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
24
frontend/src/components/features/home/shared/empty-state.tsx
Normal file
24
frontend/src/components/features/home/shared/empty-state.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
inputValue: string;
|
||||
searchMessage?: string;
|
||||
emptyMessage?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
inputValue,
|
||||
searchMessage = "No items found",
|
||||
emptyMessage = "No items available",
|
||||
testId = "dropdown-empty",
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<li
|
||||
className="px-3 py-2 text-[#B7BDC2] text-sm rounded-lg mx-0.5 my-0.5"
|
||||
data-testid={testId}
|
||||
>
|
||||
{inputValue ? searchMessage : emptyMessage}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from "react";
|
||||
|
||||
interface ErrorMessageProps {
|
||||
isError: boolean;
|
||||
message?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function ErrorMessage({
|
||||
isError,
|
||||
message = "Failed to load data",
|
||||
testId = "dropdown-error",
|
||||
}: ErrorMessageProps) {
|
||||
if (!isError) return null;
|
||||
|
||||
return (
|
||||
<div className="text-red-500 text-sm mt-1" data-testid={testId}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import {
|
||||
UseComboboxGetMenuPropsOptions,
|
||||
UseComboboxGetItemPropsOptions,
|
||||
} from "downshift";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface GenericDropdownMenuProps<T> {
|
||||
isOpen: boolean;
|
||||
filteredItems: T[];
|
||||
inputValue: string;
|
||||
highlightedIndex: number;
|
||||
selectedItem: T | null;
|
||||
getMenuProps: <Options>(
|
||||
options?: UseComboboxGetMenuPropsOptions & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
onScroll?: (event: React.UIEvent<HTMLUListElement>) => void;
|
||||
menuRef?: React.RefObject<HTMLUListElement | null>;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
highlightedIndex: number,
|
||||
selectedItem: T | null,
|
||||
getItemProps: <Options>(
|
||||
options: UseComboboxGetItemPropsOptions<T> & Options,
|
||||
) => any, // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
) => React.ReactNode;
|
||||
renderEmptyState: (inputValue: string) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenu<T>({
|
||||
isOpen,
|
||||
filteredItems,
|
||||
inputValue,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getMenuProps,
|
||||
getItemProps,
|
||||
onScroll,
|
||||
menuRef,
|
||||
renderItem,
|
||||
renderEmptyState,
|
||||
}: GenericDropdownMenuProps<T>) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<ul
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getMenuProps({
|
||||
ref: menuRef,
|
||||
className: cn(
|
||||
"absolute z-10 w-full bg-[#454545] border border-[#717888] rounded-xl shadow-lg max-h-60 overflow-auto",
|
||||
"focus:outline-none p-1 gap-2 flex flex-col",
|
||||
),
|
||||
onScroll,
|
||||
})}
|
||||
>
|
||||
{filteredItems.length === 0
|
||||
? renderEmptyState(inputValue)
|
||||
: filteredItems.map((item, index) =>
|
||||
renderItem(
|
||||
item,
|
||||
index,
|
||||
highlightedIndex,
|
||||
selectedItem,
|
||||
getItemProps,
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/features/home/shared/index.ts
Normal file
7
frontend/src/components/features/home/shared/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { GenericDropdownMenu } from "./generic-dropdown-menu";
|
||||
export { EmptyState } from "./empty-state";
|
||||
export { ErrorMessage } from "./error-message";
|
||||
export { LoadingSpinner } from "./loading-spinner";
|
||||
export { ClearButton } from "./clear-button";
|
||||
export { ToggleButton } from "./toggle-button";
|
||||
export type { GenericDropdownMenuProps } from "./generic-dropdown-menu";
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
hasSelection: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export function LoadingSpinner({
|
||||
hasSelection,
|
||||
testId = "dropdown-loading",
|
||||
}: LoadingSpinnerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-1/2 transform -translate-y-1/2",
|
||||
hasSelection ? "right-16" : "right-12",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="animate-spin h-4 w-4 border-2 border-blue-500 border-t-transparent rounded-full"
|
||||
data-testid={testId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ToggleButtonProps {
|
||||
isOpen: boolean;
|
||||
disabled: boolean;
|
||||
getToggleButtonProps: (
|
||||
props?: Record<string, unknown>,
|
||||
) => Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function ToggleButton({
|
||||
isOpen,
|
||||
disabled,
|
||||
getToggleButtonProps,
|
||||
}: ToggleButtonProps) {
|
||||
return (
|
||||
<button
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...getToggleButtonProps({
|
||||
disabled,
|
||||
className: cn(
|
||||
"p-1 text-[#B7BDC2] hover:text-[#ECEDEE]",
|
||||
"disabled:cursor-not-allowed disabled:opacity-60",
|
||||
),
|
||||
})}
|
||||
type="button"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className={cn("w-4 h-4 transition-transform", isOpen && "rotate-180")}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function MicroagentManagementAccordionTitle({
|
||||
<TooltipButton
|
||||
tooltip={repository.full_name}
|
||||
ariaLabel={repository.full_name}
|
||||
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[200px] translate-y-[-1px]"
|
||||
className="text-white text-base font-normal bg-transparent p-0 min-w-0 h-auto cursor-pointer truncate max-w-[194px] translate-y-[-1px]"
|
||||
testId="repository-name-tooltip"
|
||||
placement="bottom"
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
// Handle error events
|
||||
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
|
||||
@@ -65,16 +66,10 @@ const getConversationInstructions = (
|
||||
gitProvider: Provider,
|
||||
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
|
||||
|
||||
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
|
||||
|
||||
- This is the instructions about what the microagent should do: ${formData.query}
|
||||
|
||||
${
|
||||
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the instructions about what the microagent should do: ${formData.query}. ${
|
||||
formData.triggers && formData.triggers.length > 0
|
||||
? `
|
||||
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
|
||||
`
|
||||
: "- Please be noted that the microagent doesn't have any triggers."
|
||||
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
|
||||
: "Please be noted that the microagent doesn't have any triggers."
|
||||
}
|
||||
|
||||
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
|
||||
@@ -91,16 +86,10 @@ const getUpdateConversationInstructions = (
|
||||
) => `Update the microagent for the repository ${repositoryName} by following the steps below:
|
||||
|
||||
|
||||
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered).
|
||||
|
||||
- This is the updated instructions about what the microagent should do: ${formData.query}
|
||||
|
||||
${
|
||||
- Step 1: Update the microagent. This is the path of the microagent: ${formData.microagentPath} (The updated microagent must be in the .openhands/microagents folder and should be able to perform the described task when triggered). This is the updated instructions about what the microagent should do: ${formData.query}. ${
|
||||
formData.triggers && formData.triggers.length > 0
|
||||
? `
|
||||
- This is the triggers of the microagent: ${formData.triggers.join(", ")}
|
||||
`
|
||||
: "- Please be noted that the microagent doesn't have any triggers."
|
||||
? `This is the triggers of the microagent: ${formData.triggers.join(", ")}`
|
||||
: "Please be noted that the microagent doesn't have any triggers."
|
||||
}
|
||||
|
||||
- Step 2: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
|
||||
@@ -119,6 +108,8 @@ export function MicroagentManagementContent() {
|
||||
learnThisRepoModalVisible,
|
||||
} = useSelector((state: RootState) => state.microagentManagement);
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const dispatch = useDispatch();
|
||||
@@ -182,11 +173,7 @@ export function MicroagentManagementContent() {
|
||||
// Check if agent has finished and we have a PR
|
||||
if (isOpenHandsEvent(socketEvent) && isFinishAction(socketEvent)) {
|
||||
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
|
||||
if (prUrl) {
|
||||
displaySuccessToast(
|
||||
t(I18nKey.MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW),
|
||||
);
|
||||
} else {
|
||||
if (!prUrl) {
|
||||
// Agent finished but no PR found
|
||||
displaySuccessToast(t(I18nKey.MICROAGENT_MANAGEMENT$PR_NOT_CREATED));
|
||||
}
|
||||
@@ -253,7 +240,6 @@ export function MicroagentManagementContent() {
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: repositoryName,
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
@@ -290,6 +276,12 @@ export function MicroagentManagementContent() {
|
||||
const repositoryName = repository.full_name;
|
||||
const gitProvider = repository.git_provider;
|
||||
|
||||
const createMicroagent = {
|
||||
repo: repositoryName,
|
||||
git_provider: gitProvider,
|
||||
title: formData.query,
|
||||
};
|
||||
|
||||
// Launch a new conversation to help the user understand the repo
|
||||
createConversationAndSubscribe({
|
||||
query: formData.query,
|
||||
@@ -299,6 +291,7 @@ export function MicroagentManagementContent() {
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
onSuccessCallback: () => {
|
||||
hideLearnThisRepoModal();
|
||||
},
|
||||
@@ -329,11 +322,18 @@ export function MicroagentManagementContent() {
|
||||
</>
|
||||
);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
if (width < 1024) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col gap-6">
|
||||
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] max-h-[494px] min-h-[494px]">
|
||||
<MicroagentManagementSidebar isSmallerScreen />
|
||||
{providersAreSet && (
|
||||
<MicroagentManagementSidebar
|
||||
isSmallerScreen
|
||||
providers={providers}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full rounded-lg border border-[#525252] bg-[#24272E] flex-1 min-h-[494px]">
|
||||
<MicroagentManagementMain />
|
||||
@@ -345,7 +345,7 @@ export function MicroagentManagementContent() {
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E] overflow-hidden">
|
||||
<MicroagentManagementSidebar />
|
||||
{providersAreSet && <MicroagentManagementSidebar providers={providers} />}
|
||||
<div className="flex-1">
|
||||
<MicroagentManagementMain />
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@@ -76,23 +76,25 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
query: finalQuery,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
@@ -244,7 +246,6 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
|
||||
@@ -59,8 +59,10 @@ export function MicroagentManagementMicroagentCard({
|
||||
if (runtimeStatus === "STATUS$ERROR") {
|
||||
return t(I18nKey.MICROAGENT$STATUS_ERROR);
|
||||
}
|
||||
if (conversationStatus === "RUNNING" && runtimeStatus === "STATUS$READY") {
|
||||
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
|
||||
if (conversationStatus === "RUNNING") {
|
||||
return runtimeStatus === "STATUS$READY"
|
||||
? t(I18nKey.MICROAGENT$STATUS_OPENING_PR)
|
||||
: t(I18nKey.COMMON$STARTING);
|
||||
}
|
||||
return "";
|
||||
}, [conversationStatus, runtimeStatus, t, hasPr]);
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
|
||||
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RootState } from "#/store";
|
||||
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface MicroagentManagementRepoMicroagentsProps {
|
||||
repository: GitRepository;
|
||||
@@ -22,6 +25,8 @@ export function MicroagentManagementRepoMicroagents({
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { full_name: repositoryName } = repository;
|
||||
|
||||
// Extract owner and repo from repositoryName (format: "owner/repo")
|
||||
@@ -37,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
data: conversations,
|
||||
isLoading: isLoadingConversations,
|
||||
isError: isErrorConversations,
|
||||
} = useSearchConversations(
|
||||
} = useMicroagentManagementConversations(
|
||||
repositoryName,
|
||||
"microagent_management",
|
||||
undefined,
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
@@ -103,34 +108,47 @@ export function MicroagentManagementRepoMicroagents({
|
||||
const numberOfMicroagents = microagents?.length || 0;
|
||||
const numberOfConversations = conversations?.length || 0;
|
||||
const totalItems = numberOfMicroagents + numberOfConversations;
|
||||
const hasMicroagents = numberOfMicroagents > 0;
|
||||
const hasConversations = numberOfConversations > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{totalItems === 0 && (
|
||||
<MicroagentManagementLearnThisRepo repository={repository} />
|
||||
)}
|
||||
|
||||
{/* Render microagents */}
|
||||
{numberOfMicroagents > 0 &&
|
||||
microagents?.map((microagent) => (
|
||||
<div key={microagent.name} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
microagent={microagent}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hasMicroagents && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-md text-white font-medium leading-5 mb-4">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS)}
|
||||
</span>
|
||||
{microagents?.map((microagent) => (
|
||||
<div key={microagent.name} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
microagent={microagent}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Render conversations */}
|
||||
{numberOfConversations > 0 &&
|
||||
conversations?.map((conversation) => (
|
||||
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
conversation={conversation}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{hasConversations && (
|
||||
<div className={cn("flex flex-col", hasMicroagents && "mt-4")}>
|
||||
<span className="text-md text-white font-medium leading-5 mb-4">
|
||||
{t(I18nKey.COMMON$IN_PROGRESS)}
|
||||
</span>
|
||||
{conversations?.map((conversation) => (
|
||||
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
|
||||
<MicroagentManagementMicroagentCard
|
||||
conversation={conversation}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Accordion, AccordionItem } from "@heroui/react";
|
||||
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { TabType } from "#/types/microagent-management";
|
||||
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { DOCUMENTATION_URL } from "#/utils/constants";
|
||||
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
|
||||
type MicroagentManagementRepositoriesProps = {
|
||||
repositories: GitRepository[];
|
||||
@@ -21,23 +18,9 @@ export function MicroagentManagementRepositories({
|
||||
tabType,
|
||||
}: MicroagentManagementRepositoriesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const numberOfRepoMicroagents = repositories.length;
|
||||
|
||||
// Filter repositories based on search query
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return repositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
return repositories.filter((repository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, searchQuery]);
|
||||
|
||||
if (numberOfRepoMicroagents === 0) {
|
||||
if (tabType === "personal") {
|
||||
return (
|
||||
@@ -73,25 +56,6 @@ export function MicroagentManagementRepositories({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* Search Input */}
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Repositories Accordion */}
|
||||
<Accordion
|
||||
variant="splitted"
|
||||
@@ -104,7 +68,7 @@ export function MicroagentManagementRepositories({
|
||||
}}
|
||||
selectionMode="multiple"
|
||||
>
|
||||
{filteredRepositories.map((repository) => (
|
||||
{repositories.map((repository) => (
|
||||
<AccordionItem
|
||||
key={repository.id}
|
||||
aria-label={repository.full_name}
|
||||
|
||||
@@ -1,59 +1,108 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 { useGitRepositories } from "#/hooks/query/use-git-repositories";
|
||||
import { GitProviderDropdown } from "#/components/features/home/git-provider-dropdown";
|
||||
import {
|
||||
setPersonalRepositories,
|
||||
setOrganizationRepositories,
|
||||
setRepositories,
|
||||
} from "#/state/microagent-management-slice";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface MicroagentManagementSidebarProps {
|
||||
isSmallerScreen?: boolean;
|
||||
providers: Provider[];
|
||||
}
|
||||
|
||||
export function MicroagentManagementSidebar({
|
||||
isSmallerScreen = false,
|
||||
providers,
|
||||
}: MicroagentManagementSidebarProps) {
|
||||
const [selectedProvider, setSelectedProvider] = useState<Provider | null>(
|
||||
providers.length > 0 ? providers[0] : null,
|
||||
);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
const selectedProvider = providers.length > 0 ? providers[0] : null;
|
||||
const { data: repositories, isLoading } =
|
||||
useUserRepositories(selectedProvider);
|
||||
|
||||
const { data: repositories, isLoading } = useGitRepositories({
|
||||
provider: selectedProvider,
|
||||
pageSize: 200,
|
||||
enabled: !!selectedProvider,
|
||||
});
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
useEffect(() => {
|
||||
if (providers.length > 0 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
}
|
||||
}, [providers, selectedProvider]);
|
||||
|
||||
const handleProviderChange = (provider: Provider | null) => {
|
||||
setSelectedProvider(provider);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
// Filter repositories based on search query
|
||||
const filteredRepositories = useMemo(() => {
|
||||
if (!repositories?.pages) return null;
|
||||
|
||||
// Flatten all pages to get all repositories
|
||||
const allRepositories = repositories.pages.flatMap((page) => page.data);
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
return allRepositories;
|
||||
}
|
||||
|
||||
const sanitizedQuery = sanitizeQuery(searchQuery);
|
||||
return allRepositories.filter((repository: GitRepository) => {
|
||||
const sanitizedRepoName = sanitizeQuery(repository.full_name);
|
||||
return sanitizedRepoName.includes(sanitizedQuery);
|
||||
});
|
||||
}, [repositories, searchQuery, selectedProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (repositories?.pages) {
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: 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) {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
|
||||
organizationRepos.push(repo);
|
||||
} else {
|
||||
otherRepos.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(setPersonalRepositories(personalRepos));
|
||||
dispatch(setOrganizationRepositories(organizationRepos));
|
||||
dispatch(setRepositories(otherRepos));
|
||||
if (!filteredRepositories?.length) {
|
||||
dispatch(setPersonalRepositories([]));
|
||||
dispatch(setOrganizationRepositories([]));
|
||||
dispatch(setRepositories([]));
|
||||
return;
|
||||
}
|
||||
}, [repositories, dispatch]);
|
||||
|
||||
const personalRepos: GitRepository[] = [];
|
||||
const organizationRepos: GitRepository[] = [];
|
||||
const otherRepos: GitRepository[] = [];
|
||||
|
||||
filteredRepositories.forEach((repo: GitRepository) => {
|
||||
const hasOpenHandsSuffix =
|
||||
selectedProvider === "gitlab"
|
||||
? repo.full_name.endsWith("/openhands-config")
|
||||
: repo.full_name.endsWith("/.openhands");
|
||||
|
||||
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
|
||||
personalRepos.push(repo);
|
||||
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
|
||||
organizationRepos.push(repo);
|
||||
} else {
|
||||
otherRepos.push(repo);
|
||||
}
|
||||
});
|
||||
|
||||
dispatch(setPersonalRepositories(personalRepos));
|
||||
dispatch(setOrganizationRepositories(organizationRepos));
|
||||
dispatch(setRepositories(otherRepos));
|
||||
}, [filteredRepositories, selectedProvider, dispatch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -63,6 +112,39 @@ export function MicroagentManagementSidebar({
|
||||
)}
|
||||
>
|
||||
<MicroagentManagementSidebarHeader />
|
||||
|
||||
{/* Provider Selection */}
|
||||
{providers.length > 1 && (
|
||||
<div className="mt-6">
|
||||
<GitProviderDropdown
|
||||
providers={providers}
|
||||
value={selectedProvider}
|
||||
placeholder="Select Provider"
|
||||
onChange={handleProviderChange}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="flex flex-col gap-2 w-full mt-6">
|
||||
<label htmlFor="repository-search" className="sr-only">
|
||||
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
|
||||
</label>
|
||||
<input
|
||||
id="repository-search"
|
||||
name="repository-search"
|
||||
type="text"
|
||||
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed h-10 box-shadow-none outline-none",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-4 flex-1">
|
||||
<Spinner size="sm" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import { useEffect, useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
@@ -11,14 +11,8 @@ import XIcon from "#/icons/x.svg?react";
|
||||
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 { GitRepository } from "#/types/git";
|
||||
import { useRepositoryMicroagentContent } from "#/hooks/query/use-repository-microagent-content";
|
||||
import {
|
||||
BranchDropdown,
|
||||
BranchLoadingState,
|
||||
BranchErrorState,
|
||||
} from "../home/repository-selection";
|
||||
|
||||
interface MicroagentManagementUpsertMicroagentModalProps {
|
||||
onConfirm: (formData: MicroagentFormData) => void;
|
||||
@@ -37,7 +31,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
|
||||
const [triggers, setTriggers] = useState<string[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
@@ -49,9 +42,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
|
||||
const { microagent } = selectedMicroagentItem ?? {};
|
||||
|
||||
// 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,
|
||||
@@ -70,38 +60,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
}
|
||||
}, [isUpdate, microagentContentData]);
|
||||
|
||||
const {
|
||||
data: branches,
|
||||
isLoading: isLoadingBranches,
|
||||
isError: isBranchesError,
|
||||
} = useRepositoryBranches(selectedRepository?.full_name || null);
|
||||
|
||||
const branchesItems = branches?.map((branch) => ({
|
||||
key: branch.name,
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Auto-select main or master branch if it exists.
|
||||
useEffect(() => {
|
||||
if (
|
||||
branches &&
|
||||
branches.length > 0 &&
|
||||
!selectedBranch &&
|
||||
!isLoadingBranches
|
||||
) {
|
||||
// Look for main or master branch
|
||||
const mainBranch = branches.find((branch) => branch.name === "main");
|
||||
const masterBranch = branches.find((branch) => branch.name === "master");
|
||||
|
||||
// Select main if it exists, otherwise select master if it exists
|
||||
if (mainBranch) {
|
||||
setSelectedBranch(mainBranch);
|
||||
} else if (masterBranch) {
|
||||
setSelectedBranch(masterBranch);
|
||||
}
|
||||
}
|
||||
}, [branches, isLoadingBranches, selectedBranch]);
|
||||
|
||||
const modalTitle = useMemo(() => {
|
||||
if (isUpdate) {
|
||||
return t(I18nKey.MICROAGENT_MANAGEMENT$UPDATE_MICROAGENT);
|
||||
@@ -134,7 +92,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
triggers,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
microagentPath: microagent?.path || "",
|
||||
});
|
||||
};
|
||||
@@ -147,67 +104,10 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onConfirm({
|
||||
query: query.trim(),
|
||||
triggers,
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
microagentPath: microagent?.path || "",
|
||||
});
|
||||
};
|
||||
|
||||
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 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 for branch selector based on the loading/error state
|
||||
const renderBranchSelector = () => {
|
||||
if (!selectedRepository) {
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={[]}
|
||||
onSelectionChange={() => {}}
|
||||
onInputChange={() => {}}
|
||||
isDisabled
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingBranches) {
|
||||
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
if (isBranchesError) {
|
||||
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BranchDropdown
|
||||
items={branchesItems || []}
|
||||
onSelectionChange={handleBranchSelection}
|
||||
onInputChange={handleBranchInputChange}
|
||||
isDisabled={false}
|
||||
selectedKey={selectedBranch?.name}
|
||||
wrapperClassName="max-w-full w-full"
|
||||
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<ModalBody className="items-start rounded-[12px] p-6 min-w-[611px]">
|
||||
@@ -236,7 +136,6 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
{renderBranchSelector()}
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
@@ -301,15 +200,10 @@ export function MicroagentManagementUpsertMicroagentModal({
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
isBranchesError ||
|
||||
(isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
!query.trim() || isLoading || (isUpdate && isLoadingContent) // Disable while loading content for updates
|
||||
}
|
||||
>
|
||||
{isLoading || isLoadingBranches || (isUpdate && isLoadingContent)
|
||||
{isLoading || (isUpdate && isLoadingContent)
|
||||
? t(I18nKey.HOME$LOADING)
|
||||
: t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
|
||||
@@ -33,6 +33,7 @@ interface ConversationSubscriptionsContextType {
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||
baseUrl: string;
|
||||
socketPath?: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => void;
|
||||
unsubscribeFromConversation: (conversationId: string) => void;
|
||||
@@ -95,10 +96,10 @@ export function ConversationSubscriptionsProvider({
|
||||
[],
|
||||
);
|
||||
|
||||
const unsubscribeFromConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
// Get a local reference to the socket data to avoid race conditions
|
||||
const socketData = conversationSockets[conversationId];
|
||||
const unsubscribeFromConversation = useCallback((conversationId: string) => {
|
||||
// Use functional update to access current socket data and perform cleanup
|
||||
setConversationSockets((prev) => {
|
||||
const socketData = prev[conversationId];
|
||||
|
||||
if (socketData) {
|
||||
const { socket } = socketData;
|
||||
@@ -112,24 +113,23 @@ export function ConversationSubscriptionsProvider({
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
// Update state to remove the socket
|
||||
setConversationSockets((prev) => {
|
||||
const newSockets = { ...prev };
|
||||
delete newSockets[conversationId];
|
||||
return newSockets;
|
||||
});
|
||||
|
||||
// Remove from active IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.filter((id) => id !== conversationId),
|
||||
);
|
||||
|
||||
// Clean up event handler reference
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
|
||||
// Remove the socket from state
|
||||
const newSockets = { ...prev };
|
||||
delete newSockets[conversationId];
|
||||
return newSockets;
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
return prev; // No change if socket not found
|
||||
});
|
||||
|
||||
// Remove from active IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.filter((id) => id !== conversationId),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const subscribeToConversation = useCallback(
|
||||
(options: {
|
||||
@@ -137,10 +137,17 @@ export function ConversationSubscriptionsProvider({
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket" | "enterprise_sso")[];
|
||||
baseUrl: string;
|
||||
socketPath?: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
|
||||
options;
|
||||
const {
|
||||
conversationId,
|
||||
sessionApiKey,
|
||||
providersSet,
|
||||
baseUrl,
|
||||
socketPath,
|
||||
onEvent,
|
||||
} = options;
|
||||
|
||||
// If already subscribed, don't create a new subscription
|
||||
if (conversationSockets[conversationId]) {
|
||||
@@ -173,9 +180,7 @@ export function ConversationSubscriptionsProvider({
|
||||
if (isErrorEvent(event) || isAgentStatusError(event)) {
|
||||
renderConversationErroredToast(
|
||||
conversationId,
|
||||
isErrorEvent(event)
|
||||
? event.message
|
||||
: "Unknown error, please try again",
|
||||
isErrorEvent(event) ? event.message : "MICROAGENT$UNKNOWN_ERROR",
|
||||
);
|
||||
} else if (isStatusUpdate(event)) {
|
||||
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
|
||||
@@ -199,6 +204,7 @@ export function ConversationSubscriptionsProvider({
|
||||
// Create socket connection
|
||||
const socket = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
path: socketPath ?? "/socket.io",
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
session_api_key: sessionApiKey,
|
||||
|
||||
@@ -317,15 +317,24 @@ export function WsClientProvider({
|
||||
session_api_key: conversation.session_api_key, // Have to set here because socketio doesn't support custom headers. :(
|
||||
};
|
||||
|
||||
let baseUrl = null;
|
||||
let baseUrl: string | null = null;
|
||||
let socketPath: string;
|
||||
if (conversation.url && !conversation.url.startsWith("/")) {
|
||||
baseUrl = new URL(conversation.url).host;
|
||||
const u = new URL(conversation.url);
|
||||
baseUrl = u.host;
|
||||
const pathBeforeApi = u.pathname.split("/api/conversations")[0] || "/";
|
||||
// Socket.IO server default path is /socket.io; prefix with pathBeforeApi for path mode
|
||||
socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`;
|
||||
} else {
|
||||
baseUrl = import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
|
||||
baseUrl =
|
||||
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
|
||||
window?.location.host;
|
||||
socketPath = "/socket.io";
|
||||
}
|
||||
|
||||
sio = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
path: socketPath,
|
||||
query,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ const saveSettingsMutationFn = async (settings: Partial<PostSettings>) => {
|
||||
: settings.llm_api_key?.trim() || undefined,
|
||||
remote_runtime_resource_factor: settings.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
enable_default_condenser: settings.ENABLE_DEFAULT_CONDENSER,
|
||||
condenser_max_size:
|
||||
settings.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
|
||||
enable_sound_notifications: settings.ENABLE_SOUND_NOTIFICATIONS,
|
||||
user_consents_to_analytics: settings.user_consents_to_analytics,
|
||||
provider_tokens_set: settings.PROVIDER_TOKENS_SET,
|
||||
|
||||
126
frontend/src/hooks/query/use-branch-data.ts
Normal file
126
frontend/src/hooks/query/use-branch-data.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useMemo } from "react";
|
||||
import { useRepositoryBranchesPaginated } from "./use-repository-branches";
|
||||
import { useSearchBranches } from "./use-search-branches";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useBranchData(
|
||||
repository: string | null,
|
||||
provider: Provider,
|
||||
defaultBranch: string | null,
|
||||
processedSearchInput: string,
|
||||
inputValue: string,
|
||||
selectedBranch?: Branch | null,
|
||||
) {
|
||||
// Fetch branches with pagination
|
||||
const {
|
||||
data: branchData,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
} = useRepositoryBranchesPaginated(repository);
|
||||
|
||||
// Search branches when user types
|
||||
const { data: searchData, isLoading: isSearchLoading } = useSearchBranches(
|
||||
repository,
|
||||
processedSearchInput,
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Combine all branches from paginated data
|
||||
const allBranches = useMemo(
|
||||
() => branchData?.pages?.flatMap((page) => page.branches) || [],
|
||||
[branchData],
|
||||
);
|
||||
|
||||
// Check if default branch is in the loaded branches
|
||||
const defaultBranchInLoaded = useMemo(
|
||||
() =>
|
||||
defaultBranch
|
||||
? allBranches.find((branch) => branch.name === defaultBranch)
|
||||
: null,
|
||||
[allBranches, defaultBranch],
|
||||
);
|
||||
|
||||
// Only search for default branch if it's not already in the loaded branches
|
||||
// and we have loaded some branches (to avoid searching immediately on mount)
|
||||
const shouldSearchDefaultBranch =
|
||||
defaultBranch &&
|
||||
!defaultBranchInLoaded &&
|
||||
allBranches.length > 0 &&
|
||||
!processedSearchInput; // Don't search for default branch when user is searching
|
||||
|
||||
const { data: defaultBranchData, isLoading: isDefaultBranchLoading } =
|
||||
useSearchBranches(
|
||||
repository,
|
||||
shouldSearchDefaultBranch ? defaultBranch : "",
|
||||
30,
|
||||
provider,
|
||||
);
|
||||
|
||||
// Get branches to display with default branch prioritized
|
||||
const branches = useMemo(() => {
|
||||
// Don't use search results if input exactly matches selected branch
|
||||
const shouldUseSearch =
|
||||
processedSearchInput &&
|
||||
searchData &&
|
||||
!(selectedBranch && inputValue === selectedBranch.name);
|
||||
|
||||
let branchesToUse = shouldUseSearch ? searchData : allBranches;
|
||||
|
||||
// If we have a default branch, ensure it's at the top of the list
|
||||
if (defaultBranch) {
|
||||
// Use the already computed defaultBranchInLoaded or check in current branches
|
||||
let defaultBranchObj = shouldUseSearch
|
||||
? branchesToUse.find((branch) => branch.name === defaultBranch)
|
||||
: defaultBranchInLoaded;
|
||||
|
||||
// If not found in current branches, check if we have it from the default branch search
|
||||
if (
|
||||
!defaultBranchObj &&
|
||||
defaultBranchData &&
|
||||
defaultBranchData.length > 0
|
||||
) {
|
||||
defaultBranchObj = defaultBranchData.find(
|
||||
(branch) => branch.name === defaultBranch,
|
||||
);
|
||||
|
||||
// Add the default branch to the beginning of the list
|
||||
if (defaultBranchObj) {
|
||||
branchesToUse = [defaultBranchObj, ...branchesToUse];
|
||||
}
|
||||
} else if (defaultBranchObj) {
|
||||
// If found in current branches, move it to the front
|
||||
const otherBranches = branchesToUse.filter(
|
||||
(branch) => branch.name !== defaultBranch,
|
||||
);
|
||||
branchesToUse = [defaultBranchObj, ...otherBranches];
|
||||
}
|
||||
}
|
||||
|
||||
return branchesToUse;
|
||||
}, [
|
||||
processedSearchInput,
|
||||
searchData,
|
||||
allBranches,
|
||||
selectedBranch,
|
||||
inputValue,
|
||||
defaultBranch,
|
||||
defaultBranchInLoaded,
|
||||
defaultBranchData,
|
||||
]);
|
||||
|
||||
return {
|
||||
branches,
|
||||
allBranches,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isLoading: isLoading || isDefaultBranchLoading,
|
||||
isFetchingNextPage,
|
||||
isError,
|
||||
isSearchLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useMicroagentManagementConversations = (
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
"conversations",
|
||||
"microagent-management",
|
||||
pageId,
|
||||
limit,
|
||||
selectedRepository,
|
||||
],
|
||||
queryFn: () =>
|
||||
OpenHands.getMicroagentManagementConversations(
|
||||
selectedRepository,
|
||||
pageId,
|
||||
limit,
|
||||
),
|
||||
enabled: !!selectedRepository,
|
||||
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -1,14 +1,46 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
|
||||
export const useRepositoryBranches = (repository: string | null) =>
|
||||
useQuery<Branch[]>({
|
||||
queryKey: ["repository", repository, "branches"],
|
||||
queryFn: async () => {
|
||||
if (!repository) return [];
|
||||
return OpenHands.getRepositoryBranches(repository);
|
||||
const response = await OpenHands.getRepositoryBranches(repository);
|
||||
// Ensure we return an array even if the response is malformed
|
||||
return Array.isArray(response.branches) ? response.branches : [];
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
});
|
||||
|
||||
export const useRepositoryBranchesPaginated = (
|
||||
repository: string | null,
|
||||
perPage: number = 30,
|
||||
) =>
|
||||
useInfiniteQuery<PaginatedBranchesResponse, Error>({
|
||||
queryKey: ["repository", repository, "branches", "paginated", perPage],
|
||||
queryFn: async ({ pageParam = 1 }) => {
|
||||
if (!repository) {
|
||||
return {
|
||||
branches: [],
|
||||
has_next_page: false,
|
||||
current_page: 1,
|
||||
per_page: perPage,
|
||||
total_count: 0,
|
||||
};
|
||||
}
|
||||
return OpenHands.getRepositoryBranches(
|
||||
repository,
|
||||
pageParam as number,
|
||||
perPage,
|
||||
);
|
||||
},
|
||||
enabled: !!repository,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
getNextPageParam: (lastPage) =>
|
||||
// Use the has_next_page flag from the API response
|
||||
lastPage.has_next_page ? lastPage.current_page + 1 : undefined,
|
||||
initialPageParam: 1,
|
||||
});
|
||||
|
||||
35
frontend/src/hooks/query/use-search-branches.ts
Normal file
35
frontend/src/hooks/query/use-search-branches.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Branch } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export function useSearchBranches(
|
||||
repository: string | null,
|
||||
query: string,
|
||||
perPage: number = 30,
|
||||
selectedProvider?: Provider,
|
||||
) {
|
||||
return useQuery<Branch[]>({
|
||||
queryKey: [
|
||||
"repository",
|
||||
repository,
|
||||
"branches",
|
||||
"search",
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!repository || !query) return [];
|
||||
return OpenHands.searchRepositoryBranches(
|
||||
repository,
|
||||
query,
|
||||
perPage,
|
||||
selectedProvider,
|
||||
);
|
||||
},
|
||||
enabled: !!repository && !!query,
|
||||
staleTime: 1000 * 60 * 5,
|
||||
gcTime: 1000 * 60 * 15,
|
||||
});
|
||||
}
|
||||
@@ -22,6 +22,8 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: apiSettings.remote_runtime_resource_factor,
|
||||
PROVIDER_TOKENS_SET: apiSettings.provider_tokens_set,
|
||||
ENABLE_DEFAULT_CONDENSER: apiSettings.enable_default_condenser,
|
||||
CONDENSER_MAX_SIZE:
|
||||
apiSettings.condenser_max_size ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
|
||||
ENABLE_SOUND_NOTIFICATIONS: apiSettings.enable_sound_notifications,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS:
|
||||
apiSettings.enable_proactive_conversation_starters,
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
import React from "react";
|
||||
import { useQueries, type Query } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
import { AxiosError } from "axios";
|
||||
import { useCreateConversation } from "./mutation/use-create-conversation";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { CreateMicroagent } from "#/api/open-hands.types";
|
||||
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { renderConversationStartingToast } from "#/components/features/chat/microagent/microagent-status-toast";
|
||||
|
||||
interface ConversationData {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
baseUrl: string;
|
||||
socketPath: string;
|
||||
onEventCallback?: (event: unknown, conversationId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
|
||||
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
|
||||
* multiple conversations simultaneously.
|
||||
* This version waits for conversation status to be "RUNNING" before establishing WebSocket connection.
|
||||
* Shows immediate toast feedback and polls conversation status until ready.
|
||||
*/
|
||||
export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
@@ -20,6 +33,88 @@ export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
activeConversationIds,
|
||||
} = useConversationSubscriptions();
|
||||
|
||||
// Store conversation data immediately after creation
|
||||
const [createdConversations, setCreatedConversations] = React.useState<
|
||||
Record<string, ConversationData>
|
||||
>({});
|
||||
|
||||
// Get conversation IDs that need polling
|
||||
const conversationIdsToWatch = Object.keys(createdConversations);
|
||||
|
||||
// Poll each conversation until it's ready
|
||||
const conversationQueries = useQueries({
|
||||
queries: conversationIdsToWatch.map((conversationId) => ({
|
||||
queryKey: ["conversation-ready-poll", conversationId],
|
||||
queryFn: () => OpenHands.getConversation(conversationId),
|
||||
enabled: !!conversationId,
|
||||
refetchInterval: (query: Query<Conversation | null, AxiosError>) => {
|
||||
const status = query.state.data?.status;
|
||||
if (status === "STARTING") {
|
||||
return 3000; // Poll every 3 seconds while STARTING
|
||||
}
|
||||
return false; // Stop polling once not STARTING
|
||||
},
|
||||
retry: false,
|
||||
})),
|
||||
});
|
||||
|
||||
// Extract stable values from queries for dependency array
|
||||
const queryStatuses = conversationQueries.map((query) => query.data?.status);
|
||||
const queryDataExists = conversationQueries.map((query) => !!query.data);
|
||||
|
||||
// Effect to handle subscription when conversations are ready
|
||||
React.useEffect(() => {
|
||||
conversationQueries.forEach((query, index) => {
|
||||
const conversationId = conversationIdsToWatch[index];
|
||||
const conversationData = createdConversations[conversationId];
|
||||
|
||||
if (!query.data || !conversationData) return;
|
||||
|
||||
const { status, url, session_api_key: sessionApiKey } = query.data;
|
||||
|
||||
let { baseUrl } = conversationData;
|
||||
if (url && !url.startsWith("/")) {
|
||||
baseUrl = new URL(url).host;
|
||||
}
|
||||
|
||||
if (status === "RUNNING") {
|
||||
// Conversation is ready - subscribe to WebSocket
|
||||
subscribeToConversation({
|
||||
conversationId,
|
||||
sessionApiKey,
|
||||
providersSet: providers,
|
||||
baseUrl,
|
||||
socketPath: conversationData.socketPath,
|
||||
onEvent: conversationData.onEventCallback,
|
||||
});
|
||||
|
||||
// Remove from created conversations (cleanup)
|
||||
setCreatedConversations((prev) => {
|
||||
const newCreated = { ...prev };
|
||||
delete newCreated[conversationId];
|
||||
return newCreated;
|
||||
});
|
||||
} else if (status === "STOPPED") {
|
||||
// Dismiss the starting toast
|
||||
toast.dismiss(`starting-${conversationId}`);
|
||||
|
||||
// Remove from created conversations (cleanup)
|
||||
setCreatedConversations((prev) => {
|
||||
const newCreated = { ...prev };
|
||||
delete newCreated[conversationId];
|
||||
return newCreated;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [
|
||||
queryStatuses,
|
||||
queryDataExists,
|
||||
conversationIdsToWatch,
|
||||
createdConversations,
|
||||
subscribeToConversation,
|
||||
providers,
|
||||
]);
|
||||
|
||||
const createConversationAndSubscribe = React.useCallback(
|
||||
({
|
||||
query,
|
||||
@@ -33,7 +128,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch: string;
|
||||
branch?: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
createMicroagent?: CreateMicroagent;
|
||||
@@ -49,33 +144,46 @@ export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
// Show immediate toast to let user know something is happening
|
||||
renderConversationStartingToast(data.conversation_id);
|
||||
|
||||
// Call the success callback immediately
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback(data.conversation_id);
|
||||
}
|
||||
|
||||
// Only handle immediate post-creation tasks here
|
||||
let baseUrl = "";
|
||||
let socketPath: string;
|
||||
if (data?.url && !data.url.startsWith("/")) {
|
||||
baseUrl = new URL(data.url).host;
|
||||
const u = new URL(data.url);
|
||||
baseUrl = u.host;
|
||||
const pathBeforeApi =
|
||||
u.pathname.split("/api/conversations")[0] || "/";
|
||||
socketPath = `${pathBeforeApi.replace(/\/$/, "")}/socket.io`;
|
||||
} else {
|
||||
baseUrl =
|
||||
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
|
||||
window?.location.host;
|
||||
socketPath = "/socket.io";
|
||||
}
|
||||
|
||||
// Subscribe to the conversation
|
||||
subscribeToConversation({
|
||||
conversationId: data.conversation_id,
|
||||
sessionApiKey: data.session_api_key,
|
||||
providersSet: providers,
|
||||
baseUrl,
|
||||
onEvent: onEventCallback,
|
||||
});
|
||||
|
||||
// Call the success callback if provided
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback(data.conversation_id);
|
||||
}
|
||||
// Store conversation data for polling and eventual subscription
|
||||
setCreatedConversations((prev) => ({
|
||||
...prev,
|
||||
[data.conversation_id]: {
|
||||
conversationId: data.conversation_id,
|
||||
sessionApiKey: data.session_api_key,
|
||||
baseUrl,
|
||||
socketPath,
|
||||
onEventCallback,
|
||||
},
|
||||
}));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[createConversation, subscribeToConversation, providers],
|
||||
[createConversation],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -97,6 +97,8 @@ export enum I18nKey {
|
||||
SETTINGS$BASE_URL = "SETTINGS$BASE_URL",
|
||||
SETTINGS$AGENT = "SETTINGS$AGENT",
|
||||
SETTINGS$ENABLE_MEMORY_CONDENSATION = "SETTINGS$ENABLE_MEMORY_CONDENSATION",
|
||||
SETTINGS$CONDENSER_MAX_SIZE = "SETTINGS$CONDENSER_MAX_SIZE",
|
||||
SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP = "SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP",
|
||||
SETTINGS$LANGUAGE = "SETTINGS$LANGUAGE",
|
||||
ACTION$PUSH_TO_BRANCH = "ACTION$PUSH_TO_BRANCH",
|
||||
ACTION$PUSH_CREATE_PR = "ACTION$PUSH_CREATE_PR",
|
||||
@@ -129,7 +131,6 @@ export enum I18nKey {
|
||||
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
|
||||
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
|
||||
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
|
||||
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
|
||||
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
@@ -326,6 +327,7 @@ export enum I18nKey {
|
||||
USER$ACCOUNT_SETTINGS = "USER$ACCOUNT_SETTINGS",
|
||||
JUPYTER$OUTPUT_LABEL = "JUPYTER$OUTPUT_LABEL",
|
||||
BUTTON$STOP = "BUTTON$STOP",
|
||||
BUTTON$PAUSE = "BUTTON$PAUSE",
|
||||
BUTTON$EDIT_TITLE = "BUTTON$EDIT_TITLE",
|
||||
BUTTON$DOWNLOAD_VIA_VSCODE = "BUTTON$DOWNLOAD_VIA_VSCODE",
|
||||
BUTTON$DISPLAY_COST = "BUTTON$DISPLAY_COST",
|
||||
@@ -337,6 +339,8 @@ export enum I18nKey {
|
||||
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
|
||||
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
|
||||
CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP",
|
||||
CONVERSATION$CONFIRM_PAUSE = "CONVERSATION$CONFIRM_PAUSE",
|
||||
CONVERSATION$PAUSE_WARNING = "CONVERSATION$PAUSE_WARNING",
|
||||
CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING",
|
||||
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
|
||||
CONVERSATION$CREATED = "CONVERSATION$CREATED",
|
||||
@@ -474,7 +478,6 @@ export enum I18nKey {
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
|
||||
PROJECT_MENU_CARD$OPEN = "PROJECT_MENU_CARD$OPEN",
|
||||
ACTION_BUTTON$RESUME = "ACTION_BUTTON$RESUME",
|
||||
ACTION_BUTTON$PAUSE = "ACTION_BUTTON$PAUSE",
|
||||
BROWSER$SCREENSHOT_ALT = "BROWSER$SCREENSHOT_ALT",
|
||||
ERROR_TOAST$CLOSE_BUTTON_LABEL = "ERROR_TOAST$CLOSE_BUTTON_LABEL",
|
||||
FILE_EXPLORER$UPLOAD = "FILE_EXPLORER$UPLOAD",
|
||||
@@ -513,7 +516,6 @@ export enum I18nKey {
|
||||
STATUS$CONNECTED = "STATUS$CONNECTED",
|
||||
BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED",
|
||||
USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER",
|
||||
ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
|
||||
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
|
||||
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
|
||||
@@ -573,8 +575,6 @@ export enum I18nKey {
|
||||
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
|
||||
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
|
||||
FEEDBACK$TITLE = "FEEDBACK$TITLE",
|
||||
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
|
||||
@@ -814,8 +814,15 @@ export enum I18nKey {
|
||||
MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW = "MICROAGENT_MANAGEMENT$PR_READY_FOR_REVIEW",
|
||||
MICROAGENT_MANAGEMENT$PR_NOT_CREATED = "MICROAGENT_MANAGEMENT$PR_NOT_CREATED",
|
||||
MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT = "MICROAGENT_MANAGEMENT$ERROR_CREATING_MICROAGENT",
|
||||
MICROAGENT$STATUS_WAITING = "MICROAGENT$STATUS_WAITING",
|
||||
MICROAGENT$UNKNOWN_ERROR = "MICROAGENT$UNKNOWN_ERROR",
|
||||
MICROAGENT$CONVERSATION_STARTING = "MICROAGENT$CONVERSATION_STARTING",
|
||||
MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS = "MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS",
|
||||
SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT = "SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT",
|
||||
SETTINGS$SECURITY_ANALYZER_NONE = "SETTINGS$SECURITY_ANALYZER_NONE",
|
||||
SETTINGS$SECURITY_ANALYZER_INVARIANT = "SETTINGS$SECURITY_ANALYZER_INVARIANT",
|
||||
COMMON$HIGH_RISK = "COMMON$HIGH_RISK",
|
||||
MICROAGENT$DEFINITION = "MICROAGENT$DEFINITION",
|
||||
MICROAGENT$ADD_TO_MEMORY = "MICROAGENT$ADD_TO_MEMORY",
|
||||
COMMON$IN_PROGRESS = "COMMON$IN_PROGRESS",
|
||||
}
|
||||
|
||||
@@ -1551,6 +1551,38 @@
|
||||
"de": "Speicherkondensation aktivieren",
|
||||
"uk": "Увімкнути конденсацію пам'яті"
|
||||
},
|
||||
"SETTINGS$CONDENSER_MAX_SIZE": {
|
||||
"en": "Memory condenser max history size",
|
||||
"ja": "メモリ凝縮の最大履歴サイズ",
|
||||
"zh-CN": "内存凝缩最大历史大小",
|
||||
"zh-TW": "記憶體凝縮最大歷史大小",
|
||||
"ko-KR": "메모리 응축 최대 기록 크기",
|
||||
"no": "Maks historikkstørrelse for minnekondenser",
|
||||
"it": "Dimensione massima cronologia condensatore di memoria",
|
||||
"pt": "Tamanho máximo do histórico do condensador de memória",
|
||||
"es": "Tamaño máximo del historial del condensador de memoria",
|
||||
"ar": "الحد الأقصى لحجم سجل مكثف الذاكرة",
|
||||
"fr": "Taille maximale de l'historique du condenseur de mémoire",
|
||||
"tr": "Bellek yoğunlaştırıcı maksimum geçmiş boyutu",
|
||||
"de": "Maximale Verlaufgröße des Speicherkondensators",
|
||||
"uk": "Максимальний розмір історії конденсатора пам'яті"
|
||||
},
|
||||
"SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP": {
|
||||
"en": "After this many events, the condenser will summarize history. Minimum 20.",
|
||||
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 20。",
|
||||
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 20。",
|
||||
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 20。",
|
||||
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 20.",
|
||||
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 20.",
|
||||
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 20.",
|
||||
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 20.",
|
||||
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 20.",
|
||||
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 20.",
|
||||
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 20.",
|
||||
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 20.",
|
||||
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 20.",
|
||||
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20."
|
||||
},
|
||||
"SETTINGS$LANGUAGE": {
|
||||
"en": "Language",
|
||||
"ja": "言語",
|
||||
@@ -2063,22 +2095,6 @@
|
||||
"de": "Git-Anbieter",
|
||||
"uk": "Git-провайдер"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$TITLE": {
|
||||
"en": "Account Settings",
|
||||
"ja": "アカウント設定",
|
||||
"zh-CN": "账户设置",
|
||||
"zh-TW": "帳戶設定",
|
||||
"ko-KR": "계정 설정",
|
||||
"no": "Kontoinnstillinger",
|
||||
"it": "Impostazioni account",
|
||||
"pt": "Configurações da conta",
|
||||
"es": "Configuración de la cuenta",
|
||||
"ar": "إعدادات الحساب",
|
||||
"fr": "Paramètres du compte",
|
||||
"tr": "Hesap ayarları",
|
||||
"de": "Kontoeinstellungen",
|
||||
"uk": "Налаштування облікового запису"
|
||||
},
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL": {
|
||||
"en": "Terminal",
|
||||
"zh-CN": "终端",
|
||||
@@ -5215,6 +5231,22 @@
|
||||
"tr": "Durdur",
|
||||
"uk": "Стоп"
|
||||
},
|
||||
"BUTTON$PAUSE": {
|
||||
"en": "Pause",
|
||||
"ja": "一時停止",
|
||||
"zh-CN": "暂停",
|
||||
"zh-TW": "暫停",
|
||||
"ko-KR": "일시정지",
|
||||
"fr": "Mettre en pause",
|
||||
"es": "Pausar",
|
||||
"de": "Pausieren",
|
||||
"it": "Pausa",
|
||||
"pt": "Pausar",
|
||||
"ar": "إيقاف مؤقت",
|
||||
"no": "Pause",
|
||||
"tr": "Duraklat",
|
||||
"uk": "Призупинити"
|
||||
},
|
||||
"BUTTON$EDIT_TITLE": {
|
||||
"en": "Edit Title",
|
||||
"ja": "タイトルを編集",
|
||||
@@ -5391,8 +5423,40 @@
|
||||
"de": "Stopp bestätigen",
|
||||
"uk": "Підтвердити зупинку"
|
||||
},
|
||||
"CONVERSATION$CONFIRM_PAUSE": {
|
||||
"en": "Confirm Pause",
|
||||
"ja": "一時停止の確認",
|
||||
"zh-CN": "确认暂停",
|
||||
"zh-TW": "確認暫停",
|
||||
"ko-KR": "일시정지 확인",
|
||||
"no": "Bekreft pause",
|
||||
"it": "Conferma pausa",
|
||||
"pt": "Confirmar pausa",
|
||||
"es": "Confirmar pausa",
|
||||
"ar": "تأكيد الإيقاف المؤقت",
|
||||
"fr": "Confirmer la mise en pause",
|
||||
"tr": "Duraklatmayı Onayla",
|
||||
"de": "Pause bestätigen",
|
||||
"uk": "Підтвердити призупинення"
|
||||
},
|
||||
"CONVERSATION$PAUSE_WARNING": {
|
||||
"en": "Are you sure you want to pause this conversation?",
|
||||
"ja": "この会話を一時停止してもよろしいですか?",
|
||||
"zh-CN": "您确定要暂停此对话吗?",
|
||||
"zh-TW": "您確定要暫停此對話嗎?",
|
||||
"ko-KR": "이 대화를 일시정지하시겠습니까?",
|
||||
"no": "Er du sikker på at du vil pause denne samtalen?",
|
||||
"it": "Sei sicuro di voler mettere in pausa questa conversazione?",
|
||||
"pt": "Tem certeza de que deseja pausar esta conversa?",
|
||||
"es": "¿Está seguro de que desea pausar esta conversación?",
|
||||
"ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة مؤقتًا؟",
|
||||
"fr": "Êtes-vous sûr de vouloir mettre cette conversation en pause ?",
|
||||
"tr": "Bu konuşmayı duraklatmak istediğinizden emin misiniz?",
|
||||
"de": "Sind Sie sicher, dass Sie dieses Gespräch pausieren möchten?",
|
||||
"uk": "Ви впевнені, що хочете призупинити цю розмову?"
|
||||
},
|
||||
"CONVERSATION$STOP_WARNING": {
|
||||
"en": "Are you sure you want to stop this conversation?",
|
||||
"en": "Are you sure you want to pause this conversation?",
|
||||
"ja": "この会話を停止してもよろしいですか?",
|
||||
"zh-CN": "您确定要停止此对话吗?",
|
||||
"zh-TW": "您確定要停止此對話嗎?",
|
||||
@@ -7583,22 +7647,6 @@
|
||||
"tr": "Ajan görevine devam et",
|
||||
"uk": "Відновити завдання агента"
|
||||
},
|
||||
"ACTION_BUTTON$PAUSE": {
|
||||
"en": "Pause the current task",
|
||||
"zh-CN": "暂停",
|
||||
"zh-TW": "暫停",
|
||||
"ko-KR": "일시정지",
|
||||
"ja": "一時停止",
|
||||
"no": "Sett gjeldende oppgave på pause",
|
||||
"ar": "إيقاف المهمة الحالية مؤقتاً",
|
||||
"de": "Aktuelle Aufgabe pausieren",
|
||||
"fr": "Mettre en pause la tâche actuelle",
|
||||
"it": "Metti in pausa il compito corrente",
|
||||
"pt": "Pausar a tarefa atual",
|
||||
"es": "Pausar la tarea actual",
|
||||
"tr": "Mevcut görevi duraklat",
|
||||
"uk": "Призупинити поточне завдання"
|
||||
},
|
||||
"BROWSER$SCREENSHOT_ALT": {
|
||||
"en": "Browser Screenshot",
|
||||
"zh-CN": "截图",
|
||||
@@ -8207,22 +8255,6 @@
|
||||
"tr": "Kullanıcı avatarı yer tutucusu",
|
||||
"uk": "заповнювач аватара користувача"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$SETTINGS": {
|
||||
"en": "Account Settings",
|
||||
"ja": "アカウント設定",
|
||||
"zh-CN": "账户设置",
|
||||
"zh-TW": "帳戶設定",
|
||||
"ko-KR": "계정 설정",
|
||||
"no": "Kontoinnstillinger",
|
||||
"it": "Impostazioni account",
|
||||
"pt": "Configurações da conta",
|
||||
"es": "Configuración de la cuenta",
|
||||
"ar": "إعدادات الحساب",
|
||||
"fr": "Paramètres du compte",
|
||||
"tr": "Hesap ayarları",
|
||||
"de": "Kontoeinstellungen",
|
||||
"uk": "Налаштування облікового запису"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$LOGOUT": {
|
||||
"en": "Logout",
|
||||
"ja": "ログアウト",
|
||||
@@ -9167,38 +9199,6 @@
|
||||
"tr": "Bekleme listesine katıl",
|
||||
"uk": "Приєднатися до списку очікування"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS": {
|
||||
"en": "Additional Settings",
|
||||
"ja": "追加設定",
|
||||
"zh-CN": "附加设置",
|
||||
"zh-TW": "附加設定",
|
||||
"ko-KR": "추가 설정",
|
||||
"de": "Zusätzliche Einstellungen",
|
||||
"no": "Ytterligere innstillinger",
|
||||
"it": "Impostazioni aggiuntive",
|
||||
"pt": "Configurações adicionais",
|
||||
"es": "Configuraciones adicionales",
|
||||
"ar": "إعدادات إضافية",
|
||||
"fr": "Paramètres supplémentaires",
|
||||
"tr": "Ek Ayarlar",
|
||||
"uk": "Додаткові налаштування"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB": {
|
||||
"en": "Disconnect from GitHub",
|
||||
"ja": "GitHubから切断",
|
||||
"zh-CN": "断开与GitHub的连接",
|
||||
"zh-TW": "中斷與GitHub的連接",
|
||||
"ko-KR": "GitHub 연결 해제",
|
||||
"de": "Von GitHub trennen",
|
||||
"no": "Koble fra GitHub",
|
||||
"it": "Disconnetti da GitHub",
|
||||
"pt": "Desconectar do GitHub",
|
||||
"es": "Desconectar de GitHub",
|
||||
"ar": "قطع الاتصال من GitHub",
|
||||
"fr": "Se déconnecter de GitHub",
|
||||
"tr": "GitHub'dan bağlantıyı kes",
|
||||
"uk": "Відключитися від GitHub"
|
||||
},
|
||||
"CONVERSATION$DELETE_WARNING": {
|
||||
"en": "Are you sure you want to delete this conversation? This action cannot be undone.",
|
||||
"ja": "この会話を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@@ -11536,20 +11536,20 @@
|
||||
"uk": "Визначте тригери для мікроагента"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS": {
|
||||
"en": "Help text describing valid triggers.",
|
||||
"ja": "有効なトリガーについて説明するヘルプテキスト。",
|
||||
"zh-CN": "描述有效触发器的帮助文本。",
|
||||
"zh-TW": "描述有效觸發條件的說明文字。",
|
||||
"ko-KR": "유효한 트리거를 설명하는 도움말 텍스트입니다.",
|
||||
"no": "Hjelpetekst som beskriver gyldige utløsere.",
|
||||
"it": "Testo di aiuto che descrive i trigger validi.",
|
||||
"pt": "Texto de ajuda descrevendo gatilhos válidos.",
|
||||
"es": "Texto de ayuda que describe desencadenantes válidos.",
|
||||
"ar": "نص المساعدة الذي يصف المشغلات الصالحة.",
|
||||
"fr": "Texte d'aide décrivant les déclencheurs valides.",
|
||||
"tr": "Geçerli tetikleyicileri açıklayan yardım metni.",
|
||||
"de": "Hilfetext, der gültige Auslöser beschreibt.",
|
||||
"uk": "Текст довідки, що описує дійсні тригери."
|
||||
"en": "Enter a keyword that OpenHands will use to trigger this microagent (Optional).",
|
||||
"ja": "OpenHandsがこのマイクロエージェントを起動するために使用するキーワードを入力してください(任意)。",
|
||||
"zh-CN": "输入OpenHands将用于触发此微代理的关键字(可选)。",
|
||||
"zh-TW": "輸入OpenHands將用於觸發此微代理的關鍵字(可選)。",
|
||||
"ko-KR": "OpenHands가 이 마이크로에이전트를 트리거하는 데 사용할 키워드를 입력하세요(선택 사항).",
|
||||
"no": "Skriv inn et nøkkelord som OpenHands vil bruke for å utløse denne mikroagenten (valgfritt).",
|
||||
"it": "Inserisci una parola chiave che OpenHands userà per attivare questo microagent (opzionale).",
|
||||
"pt": "Digite uma palavra-chave que o OpenHands usará para acionar este microagente (Opcional).",
|
||||
"es": "Introduce una palabra clave que OpenHands usará para activar este microagente (Opcional).",
|
||||
"ar": "أدخل كلمة مفتاحية سيستخدمها OpenHands لتشغيل هذا الوكيل الصغير (اختياري).",
|
||||
"fr": "Entrez un mot-clé qu'OpenHands utilisera pour déclencher ce microagent (facultatif).",
|
||||
"tr": "OpenHands'ın bu mikro ajanı tetiklemek için kullanacağı bir anahtar kelime girin (İsteğe bağlı).",
|
||||
"de": "Geben Sie ein Schlüsselwort ein, das OpenHands verwendet, um diesen Microagenten auszulösen (optional).",
|
||||
"uk": "Введіть ключове слово, яке OpenHands використовуватиме для запуску цього мікроагента (необов'язково)."
|
||||
},
|
||||
"COMMON$FOR_EXAMPLE": {
|
||||
"en": "For example",
|
||||
@@ -13023,6 +13023,70 @@
|
||||
"de": "Etwas ist schiefgelaufen. Versuchen Sie, den Microagenten erneut zu starten.",
|
||||
"uk": "Щось пішло не так. Спробуйте ініціювати мікроагента ще раз."
|
||||
},
|
||||
"MICROAGENT$STATUS_WAITING": {
|
||||
"en": "Waiting for runtime to start...",
|
||||
"ja": "ランタイムの開始を待機中...",
|
||||
"zh-CN": "等待运行时启动...",
|
||||
"zh-TW": "等待運行時啟動...",
|
||||
"ko-KR": "런타임 시작을 기다리는 중...",
|
||||
"no": "Venter på at runtime skal starte...",
|
||||
"it": "In attesa dell'avvio del runtime...",
|
||||
"pt": "Aguardando o runtime iniciar...",
|
||||
"es": "Esperando que inicie el runtime...",
|
||||
"ar": "في انتظار بدء وقت التشغيل...",
|
||||
"fr": "En attente du démarrage du runtime...",
|
||||
"tr": "Çalışma zamanının başlaması bekleniyor...",
|
||||
"de": "Warten auf den Start der Laufzeit...",
|
||||
"uk": "Очікування запуску середовища виконання..."
|
||||
},
|
||||
"MICROAGENT$UNKNOWN_ERROR": {
|
||||
"en": "Unknown error, please try again",
|
||||
"ja": "不明なエラーです。もう一度お試しください",
|
||||
"zh-CN": "未知错误,请重试",
|
||||
"zh-TW": "未知錯誤,請重試",
|
||||
"ko-KR": "알 수 없는 오류입니다. 다시 시도해 주세요",
|
||||
"no": "Ukjent feil, vennligst prøv igjen",
|
||||
"it": "Errore sconosciuto, riprova",
|
||||
"pt": "Erro desconhecido, tente novamente",
|
||||
"es": "Error desconocido, inténtalo de nuevo",
|
||||
"ar": "خطأ غير معروف، يرجى المحاولة مرة أخرى",
|
||||
"fr": "Erreur inconnue, veuillez réessayer",
|
||||
"tr": "Bilinmeyen hata, lütfen tekrar deneyin",
|
||||
"de": "Unbekannter Fehler, bitte versuchen Sie es erneut",
|
||||
"uk": "Невідома помилка, спробуйте ще раз"
|
||||
},
|
||||
"MICROAGENT$CONVERSATION_STARTING": {
|
||||
"en": "Starting conversation...",
|
||||
"ja": "会話を開始しています...",
|
||||
"zh-CN": "正在开始对话...",
|
||||
"zh-TW": "正在開始對話...",
|
||||
"ko-KR": "대화를 시작하는 중...",
|
||||
"no": "Starter samtale...",
|
||||
"it": "Avvio conversazione...",
|
||||
"pt": "Iniciando conversa...",
|
||||
"es": "Iniciando conversación...",
|
||||
"ar": "بدء المحادثة...",
|
||||
"fr": "Démarrage de la conversation...",
|
||||
"tr": "Konuşma başlatılıyor...",
|
||||
"de": "Gespräch wird gestartet...",
|
||||
"uk": "Розпочинається розмова..."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$EXISTING_MICROAGENTS": {
|
||||
"en": "Existing Microagents",
|
||||
"ja": "既存のマイクロエージェント",
|
||||
"zh-CN": "现有微代理",
|
||||
"zh-TW": "現有微代理",
|
||||
"ko-KR": "기존 마이크로에이전트",
|
||||
"no": "Eksisterende mikroagenter",
|
||||
"it": "Microagent esistenti",
|
||||
"pt": "Microagentes existentes",
|
||||
"es": "Microagentes existentes",
|
||||
"ar": "الوكلاء الدقيقون الحاليون",
|
||||
"fr": "Microagents existants",
|
||||
"tr": "Mevcut Mikroajanlar",
|
||||
"de": "Vorhandene Mikroagenten",
|
||||
"uk": "Існуючі мікроагенти"
|
||||
},
|
||||
"SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT": {
|
||||
"en": "LLM Analyzer (Default)",
|
||||
"ja": "LLMアナライザー(デフォルト)",
|
||||
@@ -13086,5 +13150,53 @@
|
||||
"tr": "Yüksek Risk",
|
||||
"de": "Hohes Risiko",
|
||||
"uk": "Високий ризик"
|
||||
},
|
||||
"MICROAGENT$DEFINITION": {
|
||||
"en": "Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge. They provide expert guidance, automate common tasks, and ensure consistent practices across projects.",
|
||||
"ja": "マイクロエージェントは、OpenHandsにドメイン固有の知識を追加するための専門的なプロンプトです。専門的なガイダンスを提供し、一般的なタスクを自動化し、プロジェクト全体で一貫した実践を保証します。",
|
||||
"zh-CN": "微代理是增强 OpenHands 领域知识的专用提示。它们提供专家指导,自动化常见任务,并确保项目中的一致实践。",
|
||||
"zh-TW": "微代理是增強 OpenHands 領域知識的專用提示。它們提供專家指導,自動化常見任務,並確保專案中的一致實踐。",
|
||||
"ko-KR": "마이크로에이전트는 OpenHands에 도메인별 지식을 추가하는 특화된 프롬프트입니다. 전문가의 안내를 제공하고, 일반적인 작업을 자동화하며, 프로젝트 전반에 걸쳐 일관된 관행을 보장합니다.",
|
||||
"no": "Mikroagenter er spesialiserte prompt som forbedrer OpenHands med domenespesifikk kunnskap. De gir ekspertråd, automatiserer vanlige oppgaver og sikrer konsistente praksiser på tvers av prosjekter.",
|
||||
"it": "I microagenti sono prompt specializzati che arricchiscono OpenHands con conoscenze specifiche di dominio. Forniscono guida esperta, automatizzano attività comuni e garantiscono pratiche coerenti tra i progetti.",
|
||||
"pt": "Microagentes são prompts especializados que aprimoram o OpenHands com conhecimento específico de domínio. Eles fornecem orientação especializada, automatizam tarefas comuns e garantem práticas consistentes em todos os projetos.",
|
||||
"es": "Los microagentes son prompts especializados que mejoran OpenHands con conocimientos específicos de dominio. Proporcionan orientación experta, automatizan tareas comunes y aseguran prácticas consistentes en los proyectos.",
|
||||
"ar": "الميكرووكلاء هم مطالبات متخصصة تعزز OpenHands بمعرفة متخصصة في المجال. يقدمون إرشادات خبراء، ويؤتمتون المهام الشائعة، ويضمنون ممارسات متسقة عبر المشاريع.",
|
||||
"fr": "Les microagents sont des invites spécialisées qui enrichissent OpenHands avec des connaissances spécifiques au domaine. Ils fournissent des conseils d'experts, automatisent les tâches courantes et garantissent des pratiques cohérentes dans les projets.",
|
||||
"tr": "Mikro ajanlar, OpenHands'i alanına özgü bilgilerle geliştiren özel istemlerdir. Uzman rehberliği sağlar, yaygın görevleri otomatikleştirir ve projeler arasında tutarlı uygulamalar sunar.",
|
||||
"de": "Microagents sind spezialisierte Prompts, die OpenHands mit domänenspezifischem Wissen erweitern. Sie bieten fachkundige Anleitung, automatisieren gängige Aufgaben und sorgen für konsistente Praktiken in Projekten.",
|
||||
"uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах."
|
||||
},
|
||||
"MICROAGENT$ADD_TO_MEMORY": {
|
||||
"en": "Add to Microagent Memory",
|
||||
"ja": "マイクロエージェントメモリに追加",
|
||||
"zh-CN": "添加到微代理记忆",
|
||||
"zh-TW": "加入微代理記憶體",
|
||||
"ko-KR": "마이크로에이전트 메모리에 추가",
|
||||
"no": "Legg til i mikroagentminne",
|
||||
"it": "Aggiungi alla memoria del microagente",
|
||||
"pt": "Adicionar à Memória do Microagente",
|
||||
"es": "Agregar a la memoria del microagente",
|
||||
"ar": "أضف إلى ذاكرة الميكرووكيل",
|
||||
"fr": "Ajouter à la mémoire du microagent",
|
||||
"tr": "Mikroajan Hafızasına Ekle",
|
||||
"de": "Zur Microagent-Speicher hinzufügen",
|
||||
"uk": "Додати до пам'яті мікроагента"
|
||||
},
|
||||
"COMMON$IN_PROGRESS": {
|
||||
"en": "In Progress",
|
||||
"ja": "進行中",
|
||||
"zh-CN": "进行中",
|
||||
"zh-TW": "進行中",
|
||||
"ko-KR": "진행 중",
|
||||
"no": "Pågår",
|
||||
"it": "In corso",
|
||||
"pt": "Em andamento",
|
||||
"es": "En progreso",
|
||||
"ar": "قيد التنفيذ",
|
||||
"fr": "En cours",
|
||||
"tr": "Devam Ediyor",
|
||||
"de": "In Bearbeitung",
|
||||
"uk": "В процесі"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { delay, http, HttpResponse } from "msw";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { GitRepository, Branch, PaginatedBranchesResponse } from "#/types/git";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
import { MicroagentContentResponse } from "#/api/open-hands.types";
|
||||
|
||||
// Generate a list of mock repositories with realistic data
|
||||
const generateMockRepositories = (
|
||||
@@ -19,6 +21,32 @@ const generateMockRepositories = (
|
||||
owner_type: Math.random() > 0.7 ? "organization" : "user", // 30% chance of being organization
|
||||
}));
|
||||
|
||||
// Generate mock branches for a repository
|
||||
const generateMockBranches = (count: number): Branch[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: (() => {
|
||||
if (i === 0) return "main";
|
||||
if (i === 1) return "develop";
|
||||
return `feature/branch-${i}`;
|
||||
})(),
|
||||
commit_sha: `abc123${i.toString().padStart(3, "0")}`,
|
||||
protected: i === 0, // main branch is protected
|
||||
last_push_date: new Date(
|
||||
Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
}));
|
||||
|
||||
// Generate mock microagents for a repository
|
||||
const generateMockMicroagents = (count: number): RepositoryMicroagent[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
name: `microagent-${i + 1}`,
|
||||
path: `.openhands/microagents/microagent-${i + 1}.md`,
|
||||
created_at: new Date(
|
||||
Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000,
|
||||
).toISOString(),
|
||||
git_provider: "github",
|
||||
}));
|
||||
|
||||
// Mock repositories for each provider
|
||||
const MOCK_REPOSITORIES = {
|
||||
github: generateMockRepositories(120, "github"),
|
||||
@@ -26,6 +54,12 @@ const MOCK_REPOSITORIES = {
|
||||
bitbucket: generateMockRepositories(120, "bitbucket"),
|
||||
};
|
||||
|
||||
// Mock branches (same for all repos for simplicity)
|
||||
const MOCK_BRANCHES = generateMockBranches(25);
|
||||
|
||||
// Mock microagents (same for all repos for simplicity)
|
||||
const MOCK_MICROAGENTS = generateMockMicroagents(5);
|
||||
|
||||
export const GIT_REPOSITORY_HANDLERS = [
|
||||
http.get("/api/user/repositories", async ({ request }) => {
|
||||
await delay(500); // Simulate network delay
|
||||
@@ -154,4 +188,138 @@ export const GIT_REPOSITORY_HANDLERS = [
|
||||
|
||||
return HttpResponse.json(limitedRepos);
|
||||
}),
|
||||
|
||||
// Repository branches endpoint
|
||||
http.get("/api/user/repository/branches", async ({ request }) => {
|
||||
await delay(300);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const startIndex = (page - 1) * perPage;
|
||||
const endIndex = startIndex + perPage;
|
||||
const paginatedBranches = MOCK_BRANCHES.slice(startIndex, endIndex);
|
||||
const hasNextPage = endIndex < MOCK_BRANCHES.length;
|
||||
|
||||
const response: PaginatedBranchesResponse = {
|
||||
branches: paginatedBranches,
|
||||
has_next_page: hasNextPage,
|
||||
current_page: page,
|
||||
per_page: perPage,
|
||||
total_count: MOCK_BRANCHES.length,
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
}),
|
||||
|
||||
// Search repository branches endpoint
|
||||
http.get("/api/user/search/branches", async ({ request }) => {
|
||||
await delay(200);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const repository = url.searchParams.get("repository");
|
||||
const query = url.searchParams.get("query") || "";
|
||||
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
||||
|
||||
if (!repository) {
|
||||
return HttpResponse.json("Repository parameter is required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter branches by search query
|
||||
const filteredBranches = MOCK_BRANCHES.filter((branch) =>
|
||||
branch.name.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
|
||||
// Limit results
|
||||
const limitedBranches = filteredBranches.slice(0, perPage);
|
||||
|
||||
return HttpResponse.json(limitedBranches);
|
||||
}),
|
||||
|
||||
// Repository microagents endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents",
|
||||
async ({ params }) => {
|
||||
await delay(400);
|
||||
|
||||
const { owner, repo } = params;
|
||||
|
||||
if (!owner || !repo) {
|
||||
return HttpResponse.json("Owner and repo parameters are required", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return HttpResponse.json(MOCK_MICROAGENTS);
|
||||
},
|
||||
),
|
||||
|
||||
// Repository microagent content endpoint
|
||||
http.get(
|
||||
"/api/user/repository/:owner/:repo/microagents/content",
|
||||
async ({ request, params }) => {
|
||||
await delay(300);
|
||||
|
||||
const { owner, repo } = params;
|
||||
const url = new URL(request.url);
|
||||
const filePath = url.searchParams.get("file_path");
|
||||
|
||||
if (!owner || !repo || !filePath) {
|
||||
return HttpResponse.json(
|
||||
"Owner, repo, and file_path parameters are required",
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Find the microagent by path
|
||||
const microagent = MOCK_MICROAGENTS.find((m) => m.path === filePath);
|
||||
|
||||
if (!microagent) {
|
||||
return HttpResponse.json("Microagent not found", { status: 404 });
|
||||
}
|
||||
|
||||
const response: MicroagentContentResponse = {
|
||||
content: `# ${microagent.name}
|
||||
|
||||
A helpful microagent for repository tasks.
|
||||
|
||||
## Instructions
|
||||
|
||||
This microagent helps with specific tasks related to the repository.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Describe your task clearly
|
||||
2. The microagent will analyze the context
|
||||
3. Follow the provided recommendations
|
||||
|
||||
### Capabilities
|
||||
|
||||
- Code analysis
|
||||
- Task automation
|
||||
- Best practice recommendations
|
||||
- Error detection and resolution
|
||||
|
||||
---
|
||||
|
||||
*Generated mock content for ${microagent.name}*`,
|
||||
path: microagent.path,
|
||||
git_provider: "github",
|
||||
triggers: ["code review", "bug fix", "feature development"],
|
||||
};
|
||||
|
||||
return HttpResponse.json(response);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
@@ -27,6 +27,7 @@ export const MOCK_DEFAULT_USER_SETTINGS: ApiSettings | PostApiSettings = {
|
||||
DEFAULT_SETTINGS.REMOTE_RUNTIME_RESOURCE_FACTOR,
|
||||
provider_tokens_set: {},
|
||||
enable_default_condenser: DEFAULT_SETTINGS.ENABLE_DEFAULT_CONDENSER,
|
||||
condenser_max_size: DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
|
||||
enable_sound_notifications: DEFAULT_SETTINGS.ENABLE_SOUND_NOTIFICATIONS,
|
||||
enable_proactive_conversation_starters:
|
||||
DEFAULT_SETTINGS.ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
@@ -198,7 +199,14 @@ export const handlers = [
|
||||
const body = await request.json();
|
||||
|
||||
if (body) {
|
||||
MOCK_USER_PREFERENCES.settings = MOCK_DEFAULT_USER_SETTINGS;
|
||||
const current = MOCK_USER_PREFERENCES.settings || {
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
};
|
||||
// Persist new values over current/mock defaults
|
||||
MOCK_USER_PREFERENCES.settings = {
|
||||
...current,
|
||||
...(body as Partial<ApiSettings>),
|
||||
};
|
||||
return HttpResponse.json(null, { status: 200 });
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ function LlmSettingsScreen() {
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
condenserMaxSize: false,
|
||||
});
|
||||
|
||||
// Track the currently selected model to show help text
|
||||
@@ -124,6 +125,7 @@ function LlmSettingsScreen() {
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
condenserMaxSize: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -181,6 +183,17 @@ function LlmSettingsScreen() {
|
||||
formData.get("enable-confirmation-mode-switch")?.toString() === "on";
|
||||
const enableDefaultCondenser =
|
||||
formData.get("enable-memory-condenser-switch")?.toString() === "on";
|
||||
const condenserMaxSizeStr = formData
|
||||
.get("condenser-max-size-input")
|
||||
?.toString();
|
||||
const condenserMaxSizeRaw = condenserMaxSizeStr
|
||||
? Number.parseInt(condenserMaxSizeStr, 10)
|
||||
: undefined;
|
||||
const condenserMaxSize =
|
||||
condenserMaxSizeRaw !== undefined
|
||||
? Math.max(20, condenserMaxSizeRaw)
|
||||
: undefined;
|
||||
|
||||
const securityAnalyzer = formData
|
||||
.get("security-analyzer-input")
|
||||
?.toString();
|
||||
@@ -194,6 +207,8 @@ function LlmSettingsScreen() {
|
||||
AGENT: agent,
|
||||
CONFIRMATION_MODE: confirmationMode,
|
||||
ENABLE_DEFAULT_CONDENSER: enableDefaultCondenser,
|
||||
CONDENSER_MAX_SIZE:
|
||||
condenserMaxSize ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE,
|
||||
SECURITY_ANALYZER:
|
||||
securityAnalyzer === "none"
|
||||
? null
|
||||
@@ -222,6 +237,7 @@ function LlmSettingsScreen() {
|
||||
confirmationMode: false,
|
||||
enableDefaultCondenser: false,
|
||||
securityAnalyzer: false,
|
||||
condenserMaxSize: false,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -308,6 +324,18 @@ function LlmSettingsScreen() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCondenserMaxSizeIsDirty = (value: string) => {
|
||||
const parsed = value ? Number.parseInt(value, 10) : undefined;
|
||||
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
|
||||
const condenserMaxSizeIsDirty =
|
||||
(bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
|
||||
(settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
condenserMaxSize: condenserMaxSizeIsDirty,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSecurityAnalyzerIsDirty = (securityAnalyzer: string) => {
|
||||
const securityAnalyzerIsDirty =
|
||||
securityAnalyzer !== settings?.SECURITY_ANALYZER;
|
||||
@@ -565,6 +593,26 @@ function LlmSettingsScreen() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="w-full max-w-[680px]">
|
||||
<SettingsInput
|
||||
testId="condenser-max-size-input"
|
||||
name="condenser-max-size-input"
|
||||
type="number"
|
||||
min={20}
|
||||
step={1}
|
||||
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
|
||||
defaultValue={(
|
||||
settings.CONDENSER_MAX_SIZE ??
|
||||
DEFAULT_SETTINGS.CONDENSER_MAX_SIZE
|
||||
)?.toString()}
|
||||
onChange={(value) => handleCondenserMaxSizeIsDirty(value)}
|
||||
isDisabled={!settings.ENABLE_DEFAULT_CONDENSER}
|
||||
/>
|
||||
<p className="text-xs text-tertiary-alt mt-1">
|
||||
{t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsSwitch
|
||||
testId="enable-memory-condenser-switch"
|
||||
name="enable-memory-condenser-switch"
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: 1,
|
||||
PROVIDER_TOKENS_SET: {},
|
||||
ENABLE_DEFAULT_CONDENSER: true,
|
||||
CONDENSER_MAX_SIZE: 120,
|
||||
ENABLE_SOUND_NOTIFICATIONS: false,
|
||||
USER_CONSENTS_TO_ANALYTICS: false,
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: false,
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
export type ConversationStatus = "STARTING" | "RUNNING" | "STOPPED";
|
||||
export type ConversationStatus =
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "ARCHIVED"
|
||||
| "ERROR";
|
||||
|
||||
@@ -127,6 +127,7 @@ export interface RecallObservation extends OpenHandsObservationEvent<"recall"> {
|
||||
runtime_hosts?: Record<string, number>;
|
||||
custom_secrets_descriptions?: Record<string, string>;
|
||||
additional_agent_instructions?: string;
|
||||
conversation_instructions?: string;
|
||||
date?: string;
|
||||
microagent_knowledge?: MicroagentKnowledge[];
|
||||
};
|
||||
|
||||
9
frontend/src/types/git.d.ts
vendored
9
frontend/src/types/git.d.ts
vendored
@@ -22,6 +22,14 @@ interface Branch {
|
||||
last_push_date?: string;
|
||||
}
|
||||
|
||||
interface PaginatedBranchesResponse {
|
||||
branches: Branch[];
|
||||
has_next_page: boolean;
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total_count?: number;
|
||||
}
|
||||
|
||||
interface GitRepository {
|
||||
id: string;
|
||||
full_name: string;
|
||||
@@ -31,6 +39,7 @@ interface GitRepository {
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
main_branch?: string;
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
@@ -17,7 +17,6 @@ export interface IMicroagentItem {
|
||||
export interface MicroagentFormData {
|
||||
query: string;
|
||||
triggers: string[];
|
||||
selectedBranch: string;
|
||||
microagentPath: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export enum MicroagentStatus {
|
||||
WAITING = "waiting",
|
||||
CREATING = "creating",
|
||||
COMPLETED = "completed",
|
||||
ERROR = "error",
|
||||
|
||||
@@ -47,6 +47,8 @@ export type Settings = {
|
||||
REMOTE_RUNTIME_RESOURCE_FACTOR: number | null;
|
||||
PROVIDER_TOKENS_SET: Partial<Record<Provider, string | null>>;
|
||||
ENABLE_DEFAULT_CONDENSER: boolean;
|
||||
// Maximum number of events before the condenser runs
|
||||
CONDENSER_MAX_SIZE: number | null;
|
||||
ENABLE_SOUND_NOTIFICATIONS: boolean;
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS: boolean;
|
||||
ENABLE_SOLVABILITY_ANALYSIS: boolean;
|
||||
@@ -73,6 +75,8 @@ export type ApiSettings = {
|
||||
security_analyzer: string | null;
|
||||
remote_runtime_resource_factor: number | null;
|
||||
enable_default_condenser: boolean;
|
||||
// Max size for condenser in backend settings
|
||||
condenser_max_size: number | null;
|
||||
enable_sound_notifications: boolean;
|
||||
enable_proactive_conversation_starters: boolean;
|
||||
enable_solvability_analysis: boolean;
|
||||
|
||||
@@ -244,3 +244,31 @@ export const extractRepositoryInfo = (
|
||||
|
||||
return { owner, repo, filePath };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the repository markdown creation prompt with additional PR creation instructions
|
||||
* @param gitProvider The git provider to use for generating provider-specific text
|
||||
* @param query Optional custom query to use instead of the default prompt
|
||||
* @returns The complete prompt for creating repository markdown and PR instructions
|
||||
*/
|
||||
export const getRepoMdCreatePrompt = (
|
||||
gitProvider: Provider,
|
||||
query?: string,
|
||||
): string => {
|
||||
const providerName = getProviderName(gitProvider);
|
||||
const pr = getPR(gitProvider === "gitlab");
|
||||
const prShort = getPRShort(gitProvider === "gitlab");
|
||||
|
||||
return `Please explore this repository. Create the file .openhands/microagents/repo.md with:
|
||||
${
|
||||
query
|
||||
? `- ${query}`
|
||||
: `- A description of the project
|
||||
- An overview of the file structure
|
||||
- Any information on how to run tests or other relevant commands
|
||||
- Any other information that would be helpful to a brand new developer
|
||||
Keep it short--just a few paragraphs will do.`
|
||||
}
|
||||
|
||||
Please push the changes to your branch on ${providerName} and create 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.`;
|
||||
};
|
||||
|
||||
@@ -24,12 +24,14 @@ export const VERIFIED_MODELS = [
|
||||
"kimi-k2-0711-preview",
|
||||
"qwen3-coder-480b",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
export const VERIFIED_OPENAI_MODELS = [
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
@@ -66,6 +68,7 @@ export const VERIFIED_MISTRAL_MODELS = [
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
|
||||
@@ -5,6 +5,7 @@ import "@testing-library/jest-dom/vitest";
|
||||
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn();
|
||||
HTMLElement.prototype.scrollTo = vi.fn();
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
// Mock the i18n provider
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
|
||||
@@ -217,7 +217,7 @@ class BrowsingAgent(Agent):
|
||||
messages.append(Message(role='user', content=[TextContent(text=prompt)]))
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=self.llm.format_messages_for_llm(messages),
|
||||
messages=messages,
|
||||
stop=[')```', ')\n```'],
|
||||
)
|
||||
return self.response_parser.parse(response)
|
||||
|
||||
@@ -204,7 +204,7 @@ class CodeActAgent(Agent):
|
||||
initial_user_message = self._get_initial_user_message(state.history)
|
||||
messages = self._get_messages(condensed_history, initial_user_message)
|
||||
params: dict = {
|
||||
'messages': self.llm.format_messages_for_llm(messages),
|
||||
'messages': messages,
|
||||
}
|
||||
params['tools'] = check_tools(self.tools, self.llm.config)
|
||||
params['extra_body'] = {
|
||||
|
||||
@@ -263,9 +263,36 @@ def response_to_actions(
|
||||
f'Missing required argument "task_list" for "plan" command in tool call {tool_call.function.name}'
|
||||
)
|
||||
|
||||
raw_task_list = arguments.get('task_list', [])
|
||||
if not isinstance(raw_task_list, list):
|
||||
raise FunctionCallValidationError(
|
||||
f'Invalid format for "task_list". Expected a list but got {type(raw_task_list)}.'
|
||||
)
|
||||
|
||||
# Normalize task_list to ensure it's always a list of dictionaries
|
||||
normalized_task_list = []
|
||||
for i, task in enumerate(raw_task_list):
|
||||
if isinstance(task, dict):
|
||||
# Task is already in correct format, ensure required fields exist
|
||||
normalized_task = {
|
||||
'id': task.get('id', f'task-{i + 1}'),
|
||||
'title': task.get('title', 'Untitled task'),
|
||||
'status': task.get('status', 'todo'),
|
||||
'notes': task.get('notes', ''),
|
||||
}
|
||||
else:
|
||||
# Unexpected format, raise validation error
|
||||
logger.warning(
|
||||
f'Unexpected task format in task_list: {type(task)} - {task}'
|
||||
)
|
||||
raise FunctionCallValidationError(
|
||||
f'Unexpected task format in task_list: {type(task)}. Each task shoud be a dictionary.'
|
||||
)
|
||||
normalized_task_list.append(normalized_task)
|
||||
|
||||
action = TaskTrackingAction(
|
||||
command=arguments['command'],
|
||||
task_list=arguments.get('task_list', []),
|
||||
task_list=normalized_task_list,
|
||||
)
|
||||
|
||||
# ================================================
|
||||
|
||||
@@ -38,3 +38,9 @@
|
||||
[Assistant proceeds with implementation step by step, updating tasks to in_progress and done as work progresses]
|
||||
```
|
||||
</TASK_MANAGEMENT>
|
||||
|
||||
<TASK_TRACKING_PERSISTENCE>
|
||||
* IMPORTANT: If you were using the task_tracker tool before a condensation event, continue using it after condensation
|
||||
* Check condensation summaries for TASK_TRACKING sections to maintain continuity
|
||||
* If you see a condensation event with TASK_TRACKING, immediately use task_tracker to view and continue managing them
|
||||
</TASK_TRACKING_PERSISTENCE>
|
||||
|
||||
@@ -301,10 +301,8 @@ You are an agent trying to solve a web task based on the content of the page and
|
||||
messages.append(Message(role='system', content=[TextContent(text=system_msg)]))
|
||||
messages.append(Message(role='user', content=human_prompt))
|
||||
|
||||
flat_messages = self.llm.format_messages_for_llm(messages)
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=flat_messages,
|
||||
messages=messages,
|
||||
temperature=0.0,
|
||||
stop=[')```', ')\n```'],
|
||||
)
|
||||
|
||||
@@ -522,7 +522,7 @@ def display_task_tracking_action(event: TaskTrackingAction) -> None:
|
||||
"""Display a TaskTracking action in the CLI."""
|
||||
# Display thought first if present
|
||||
if hasattr(event, 'thought') and event.thought:
|
||||
display_message(event.thought)
|
||||
display_thought_if_new(event.thought)
|
||||
|
||||
# Format the command and task list for display
|
||||
display_text = f'Command: {event.command}'
|
||||
|
||||
@@ -151,6 +151,7 @@ VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -186,6 +187,7 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'devstral-small-2507',
|
||||
|
||||
@@ -895,8 +895,10 @@ class AgentController:
|
||||
'contextwindowexceedederror' in error_str
|
||||
or 'prompt is too long' in error_str
|
||||
or 'input length and `max_tokens` exceed context limit' in error_str
|
||||
or 'please reduce the length of either one'
|
||||
in error_str # For OpenRouter context window errors
|
||||
or 'please reduce the length of either one' in error_str
|
||||
or 'the request exceeds the available context size' in error_str
|
||||
or 'context length exceeded' in error_str
|
||||
# For OpenRouter context window errors
|
||||
or (
|
||||
'sambanovaexception' in error_str
|
||||
and 'maximum context length' in error_str
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user