Compare commits

..

3 Commits

Author SHA1 Message Date
Xingyao Wang
f5d86e8132 Merge branch 'main' into fix-cli-command-interruption 2025-08-22 09:26:13 -04:00
openhands
d615fe26c0 cli: refine Ctrl+C behavior and async safety
- Double-press Ctrl+C within 400ms to send interrupt to running command
- Single Ctrl+C pauses (legacy) when command running or not
- Honor CLI config in dialogs and avoid blocking event loop via to_thread
- Debounce interrupts with asyncio.Lock to prevent races
- Use bounded reverse search on EventStream with EventFilter; rely on exit_code
- Pass config through start_pause_listener; remove ad-hoc OpenHandsConfig()
- Update help text for clarity

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-21 19:54:57 +00:00
openhands
01f28f6269 Fix issue #10434: Add command interruption support to CLI
- Enhanced Ctrl+C behavior to detect running commands and provide user options
- Added is_command_running() function to analyze event stream for active commands
- Modified process_agent_pause() to handle command interruption vs agent pause
- Added _handle_command_interrupt() with user confirmation dialog offering:
  * Kill running command (send Ctrl+C to command)
  * Continue waiting for command completion
  * Pause the entire agent
- Updated help documentation with new keyboard shortcuts section
- Maintains backward compatibility: Ctrl+C still pauses agent when no command running
- All existing CLI tests pass (237 tests)

Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-16 23:29:04 +00:00
479 changed files with 6744 additions and 20400 deletions

View File

@@ -187,7 +187,6 @@ 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

View File

@@ -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,backlog
exempt-issue-labels: 'roadmap'
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

2
.gitignore vendored
View File

@@ -257,5 +257,3 @@ containers/runtime/code
# test results
test-results
.sessions
.eval_sessions

View File

@@ -130,6 +130,7 @@ 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).

View File

@@ -363,11 +363,10 @@ classpath = "my_package.my_module.MyCustomAgent"
#confirmation_mode = false
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
# Available options: 'llm' (default), 'invariant'
#security_analyzer = "llm"
#security_analyzer = ""
# Whether to enable security analyzer
#enable_security_analyzer = true
#enable_security_analyzer = false
#################################### Condenser #################################
# Condensers control how conversation history is managed and compressed when

View File

@@ -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 openhands
RUN groupadd --gid $OPENHANDS_USER_ID app
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
usermod -aG openhands openhands && \
usermod -aG app openhands && \
usermod -aG sudo openhands && \
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
RUN chown -R openhands:openhands /app && chmod -R 770 /app
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
RUN chown -R openhands:app /app && chmod -R 770 /app
RUN sudo chown -R openhands:app $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:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
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 ./
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 ./
# 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:openhands
RUN find /app \! -group openhands -exec chgrp openhands {} +
# openhands:openhands -> openhands:app
RUN find /app \! -group app -exec chgrp app {} +
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
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
USER root

View File

@@ -54,7 +54,7 @@ else
fi
fi
fi
usermod -aG openhands enduser
usermod -aG app 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"

View File

@@ -1,5 +1,5 @@
---
title: Jira Data Center Integration (Coming soon...)
title: Jira Data Center Integration (Beta)
description: Complete guide for setting up Jira Data Center integration with OpenHands Cloud, including service account creation, personal access token generation, webhook configuration, and workspace integration setup.
---

View File

@@ -1,5 +1,5 @@
---
title: Jira Cloud Integration (Coming soon...)
title: Jira Cloud Integration
description: Complete guide for setting up Jira Cloud integration with OpenHands Cloud, including service account creation, API token generation, webhook configuration, and workspace integration setup.
---

View File

@@ -1,5 +1,5 @@
---
title: Linear Integration (Coming soon...)
title: Linear Integration
description: Complete guide for setting up Linear integration with OpenHands Cloud, including service account creation, API key generation, webhook configuration, and workspace integration setup.
---

View File

@@ -1,5 +1,5 @@
---
title: Project Management Tool Integrations (Coming soon...)
title: Project Management Tool Integrations
description: Overview of OpenHands Cloud integrations with project management platforms including Jira Cloud, Jira Data Center, and Linear. Learn about setup requirements, usage methods, and troubleshooting.
---
@@ -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 (Coming soon...)](./jira-integration.md)
- [Jira Data Center Integration (Coming soon...)](./jira-dc-integration.md)
- [Linear Integration (Coming soon...)](./linear-integration.md)
- [Jira Cloud Integration](./jira-integration.md)
- [Jira Data Center Integration](./jira-dc-integration.md)
- [Linear Integration](./linear-integration.md)
## Usage

View File

@@ -1,52 +0,0 @@
# Confirmation Mode and Security Analyzers
OpenHands provides a security framework to help protect users from potentially risky actions through **Confirmation Mode** and **Security Analyzers**. This system analyzes agent actions and prompts users for confirmation when high-risk operations are detected.
## Overview
The security system consists of two main components:
1. **Confirmation Mode**: When enabled, the agent will pause and ask for user confirmation before executing actions that are flagged as high-risk by the security analyzer.
2. **Security Analyzers**: These are modules that evaluate the risk level of agent actions and determine whether user confirmation is required.
## Configuration
### CLI
In CLI mode, confirmation is enabled by default. You will have an option to uses the LLM Analyzer and will automatically confirm LOW and MEDIUM risk actions, only prompting for HIGH risk actions.
## Security Analyzers
OpenHands includes multiple analyzers:
- **No Analyzer**: Do not use any security analyzer. The agent will prompt you to confirm *EVERY* action.
- **LLM Risk Analyzer** (default): Uses the same LLM as the agent to assess action risk levels
- **Invariant Analyzer**: Uses Invariant Labs' policy engine to evaluate action traces against security policies
### LLM Risk Analyzer
The default analyzer that leverages the agent's LLM to evaluate the security risk of each action. It considers the action type, parameters, and context to assign risk levels.
### Invariant Analyzer
An advanced analyzer that:
- Collects conversation events and parses them into a trace
- Checks the trace against an Invariant policy to classify risk (low, medium, high)
- Manages an Invariant server container automatically if needed
- Supports optional browsing-alignment and harmful-content checks
## How It Works
1. **Action Analysis**: When the agent wants to perform an action, the selected security analyzer evaluates its risk level.
2. **Risk Assessment**: The analyzer returns one of three risk levels:
- **LOW**: Action proceeds without confirmation
- **MEDIUM**: Action proceeds without confirmation (may be configurable in future)
- **HIGH**: Action is paused, and user confirmation is requested
3. **User Confirmation**: For high-risk actions, a confirmation dialog appears with:
- Description of the action
- Risk assessment explanation
- Options to approve or deny action
4. **Action Execution**: Based on user response:
- **Approve**: Action proceeds as planned
- **Deny**: Action is cancelled

View File

@@ -87,13 +87,19 @@ 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>
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
4. 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

View File

@@ -45,13 +45,6 @@ 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).
@@ -60,7 +53,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. Use `wsl -d Ubuntu` in PowerShell or search "Ubuntu" in the Start menu to access the Ubuntu terminal.
The docker command below to start the app must be run inside the WSL terminal.
</Note>
**Alternative: Windows without WSL**

View File

@@ -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

View File

@@ -38,16 +38,6 @@ 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**

View File

@@ -9,8 +9,8 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -61,15 +61,18 @@ AGENT_CLS_TO_INST_SUFFIX = {
def get_config(
metadata: EvalMetadata,
) -> OpenHandsConfig:
# Create config with EDA-specific container image
config = get_openhands_config_for_eval(
metadata=metadata,
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
# Override the container image for EDA
config.sandbox.base_container_image = 'python:3.12-bookworm'
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False

View File

@@ -17,8 +17,8 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -41,12 +41,19 @@ from openhands.utils.async_utils import call_async_from_sync
def get_config(
metadata: EvalMetadata,
) -> OpenHandsConfig:
# Create config with agent_bench-specific container image
config = get_openhands_config_for_eval(metadata=metadata)
# Override the container image for agent_bench
config.sandbox.base_container_image = 'python:3.12-slim'
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-slim'
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)
agent_config.enable_prompt_extensions = False

View File

@@ -18,7 +18,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -51,10 +50,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.11-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
sandbox_config=sandbox_config,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -16,7 +16,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -62,10 +61,15 @@ def get_config(
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = BIOCODER_BENCH_CONTAINER_IMAGE
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -19,7 +19,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -76,10 +75,15 @@ def get_config(
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -41,8 +40,14 @@ def get_config(
)
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata, runtime='docker', sandbox_config=sandbox_config
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -17,7 +17,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -115,11 +114,16 @@ def get_config(
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = base_container_image
config = get_openhands_config_for_eval(
metadata=metadata,
sandbox_config=sandbox_config,
runtime=os.environ.get('RUNTIME', 'docker'),
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -18,7 +18,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -66,10 +65,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -23,7 +23,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -61,10 +60,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'nikolaik/python-nodejs:python3.12-nodejs22'
config = get_openhands_config_for_eval(
metadata=metadata,
sandbox_config=sandbox_config,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
if metadata.agent_config:

View File

@@ -13,7 +13,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -44,10 +43,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -31,7 +31,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -65,10 +64,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -24,7 +24,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -86,10 +85,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -16,7 +16,6 @@ import ruamel.yaml
from evaluation.utils.shared import (
EvalMetadata,
get_default_sandbox_config_for_eval,
get_openhands_config_for_eval,
make_metadata,
)
from openhands.core.config import (
@@ -38,10 +37,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -23,7 +23,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -49,10 +48,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -11,7 +11,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -53,10 +52,15 @@ def get_config(
'$OH_INTERPRETER_PATH -m pip install scitools-pyke'
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -14,7 +14,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -59,10 +58,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'xingyaoww/od-eval-miniwob:v1.0'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -16,7 +16,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -111,10 +110,15 @@ def get_config(
f'$OH_INTERPRETER_PATH -m pip install {" ".join(MINT_DEPENDENCIES)}'
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -27,7 +27,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -81,10 +80,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'public.ecr.aws/i5g0m1f6/ml-bench'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -23,7 +23,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
get_openhands_config_for_eval,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
@@ -88,9 +87,13 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> OpenHandsConfig:
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
config = OpenHandsConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
return config

View File

@@ -21,7 +21,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -342,11 +341,16 @@ def get_config(
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -31,7 +31,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -175,10 +174,15 @@ def get_config(
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(

View File

@@ -13,7 +13,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -65,10 +64,16 @@ def get_config(
sandbox_config.base_container_image = (
'docker.io/xingyaoww/openhands-eval-scienceagentbench'
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
max_budget_per_task=4,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -19,7 +19,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_default_sandbox_config_for_eval,
get_openhands_config_for_eval,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
@@ -84,9 +83,13 @@ def get_config(metadata: EvalMetadata, instance: pd.Series) -> OpenHandsConfig:
dataset_name=metadata.dataset,
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
config = OpenHandsConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
return config

View File

@@ -32,7 +32,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -228,11 +227,16 @@ def get_config(
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(

View File

@@ -20,7 +20,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -200,11 +199,16 @@ def get_config(
'REPO_PATH': f'/workspace/{workspace_dir_name}/',
}
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -13,7 +13,6 @@ 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

View File

@@ -37,7 +37,6 @@ from evaluation.benchmarks.testgeneval.utils import load_testgeneval_dataset
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
get_openhands_config_for_eval,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
@@ -59,21 +58,20 @@ def get_config(instance: pd.Series) -> OpenHandsConfig:
f'Invalid container image for instance {instance["instance_id_swebench"]}.'
)
logger.info(f'Using instance container image: {base_container_image}.')
# Create custom sandbox config for testgeneval with specific requirements
sandbox_config = SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
timeout=1800, # Longer timeout than default (300)
api_key=os.environ.get('ALLHANDS_API_KEY'),
remote_runtime_api_url=os.environ.get(
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
return OpenHandsConfig(
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'eventstream'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
use_host_network=False,
timeout=1800,
api_key=os.environ.get('ALLHANDS_API_KEY'),
remote_runtime_api_url=os.environ.get(
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
),
),
)
return get_openhands_config_for_eval(
sandbox_config=sandbox_config,
runtime=os.environ.get('RUNTIME', 'docker'), # Different default runtime
workspace_base=None,
workspace_mount_path=None,
)

View File

@@ -25,7 +25,6 @@ from evaluation.utils.shared import (
assert_and_raise,
codeact_user_response,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -127,26 +126,29 @@ def get_config(
f'Submit an issue on https://github.com/All-Hands-AI/OpenHands if you run into any issues.'
)
sandbox_config = SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
# Add platform to the sandbox config to solve issue 4401
platform='linux/amd64',
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get(
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
runtime=os.environ.get('RUNTIME', 'eventstream'),
sandbox=SandboxConfig(
base_container_image=base_container_image,
enable_auto_lint=True,
use_host_network=False,
# large enough timeout, since some testcases take very long to run
timeout=300,
# Add platform to the sandbox config to solve issue 4401
platform='linux/amd64',
api_key=os.environ.get('ALLHANDS_API_KEY', None),
remote_runtime_api_url=os.environ.get(
'SANDBOX_REMOTE_RUNTIME_API_URL', 'http://localhost:8000'
),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
),
keep_runtime_alive=False,
remote_runtime_init_timeout=3600,
)
config = get_openhands_config_for_eval(
metadata=metadata,
sandbox_config=sandbox_config,
runtime=os.environ.get('RUNTIME', 'docker'),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -12,10 +12,7 @@ import tempfile
import yaml
from browsing import pre_login
from evaluation.utils.shared import (
get_default_sandbox_config_for_eval,
get_openhands_config_for_eval,
)
from evaluation.utils.shared import get_default_sandbox_config_for_eval
from openhands.controller.state.state import State
from openhands.core.config import (
LLMConfig,
@@ -45,17 +42,19 @@ def get_config(
sandbox_config.enable_auto_lint = True
# If the web services are running on the host machine, this must be set to True
sandbox_config.use_host_network = True
config = get_openhands_config_for_eval(
config = OpenHandsConfig(
run_as_openhands=False,
max_budget_per_task=4,
max_iterations=100,
save_trajectory_path=os.path.join(
mount_path_on_host, f'traj_{task_short_name}.json'
),
sandbox=sandbox_config,
# we mount trajectories path so that trajectories, generated by OpenHands
# controller, can be accessible to the evaluator file in the runtime container
sandbox_config=sandbox_config,
workspace_mount_path=mount_path_on_host,
workspace_mount_path_in_sandbox='/outputs',
)
config.save_trajectory_path = os.path.join(
mount_path_on_host, f'traj_{task_short_name}.json'
)
config.max_budget_per_task = 4
config.set_llm_config(llm_config)
if agent_config:
config.set_agent_config(agent_config)

View File

@@ -12,7 +12,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -45,10 +44,15 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.base_container_image = 'python:3.12-bookworm'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -20,7 +20,6 @@ from evaluation.utils.shared import (
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
is_fatal_evaluation_error,
make_metadata,
prepare_dataset,
@@ -161,11 +160,16 @@ def get_config(
instance_id=instance['instance_id'],
)
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -13,7 +13,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -74,10 +73,16 @@ def get_config(
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
}
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
attach_to_existing=True,
)
config.set_llm_config(
update_llm_config_for_completions_logging(

View File

@@ -13,7 +13,6 @@ from evaluation.utils.shared import (
compatibility_for_eval_history_pairs,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -66,10 +65,15 @@ def get_config(
'MAP': f'{base_url}:3000',
'HOMEPAGE': f'{base_url}:4399',
}
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
agent_config = config.get_agent_config(metadata.agent_class)

View File

@@ -10,7 +10,6 @@ from evaluation.utils.shared import (
EvalOutput,
get_default_sandbox_config_for_eval,
get_metrics,
get_openhands_config_for_eval,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
@@ -46,12 +45,18 @@ def get_config(
) -> OpenHandsConfig:
sandbox_config = get_default_sandbox_config_for_eval()
sandbox_config.platform = 'linux/amd64'
config = get_openhands_config_for_eval(
metadata=metadata,
config = OpenHandsConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox_config=sandbox_config,
max_iterations=metadata.max_iterations,
sandbox=sandbox_config,
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
# debug
debug=True,
)
config.debug = True
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config, metadata.eval_output_dir, instance_id

View File

@@ -1,209 +0,0 @@
#!/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())

View File

@@ -703,79 +703,3 @@ def get_default_sandbox_config_for_eval() -> SandboxConfig:
remote_runtime_enable_retries=True,
remote_runtime_class='sysbox',
)
def get_openhands_config_for_eval(
metadata: EvalMetadata | None = None,
sandbox_config: SandboxConfig | None = None,
runtime: str | None = None,
max_iterations: int | None = None,
default_agent: str | None = None,
enable_browser: bool = False,
workspace_base: str | None = None,
workspace_mount_path: str | None = None,
):
"""Create an OpenHandsConfig with common patterns used across evaluation scripts.
This function provides a standardized way to create OpenHands configurations
for evaluation runs, with sensible defaults that match the patterns used in
most run_infer.py scripts. Individual evaluation scripts can override specific
attributes as needed.
Args:
metadata: EvalMetadata containing agent class, max iterations, etc.
sandbox_config: Custom sandbox config. If None, uses get_default_sandbox_config_for_eval()
runtime: Runtime type. If None, uses environment RUNTIME or 'docker'
max_iterations: Max iterations for the agent. If None, uses metadata.max_iterations
default_agent: Agent class name. If None, uses metadata.agent_class
enable_browser: Whether to enable browser functionality
workspace_base: Workspace base path. Defaults to None
workspace_mount_path: Workspace mount path. Defaults to None
Returns:
OpenHandsConfig: Configured for evaluation with eval-specific overrides applied
"""
# Defer import to avoid circular imports at module load time
from openhands.core.config.openhands_config import (
OpenHandsConfig as _OHConfig, # type: ignore
)
# Use provided sandbox config or get default
if sandbox_config is None:
sandbox_config = get_default_sandbox_config_for_eval()
# Extract values from metadata if provided
if metadata is not None:
if max_iterations is None:
max_iterations = metadata.max_iterations
if default_agent is None:
default_agent = metadata.agent_class
# Use environment runtime or default
if runtime is None:
runtime = os.environ.get('RUNTIME', 'docker')
# Provide sensible defaults if still None
if default_agent is None:
default_agent = 'CodeActAgent'
if max_iterations is None:
max_iterations = 50
# Always use repo-local .eval_sessions directory (absolute path)
eval_store = os.path.abspath(os.path.join(os.getcwd(), '.eval_sessions'))
# Create the base config with evaluation-specific overrides
config = _OHConfig(
default_agent=default_agent,
run_as_openhands=False,
runtime=runtime,
max_iterations=max_iterations,
enable_browser=enable_browser,
sandbox=sandbox_config,
workspace_base=workspace_base,
workspace_mount_path=workspace_mount_path,
file_store='local',
file_store_path=eval_store,
)
return config

View File

@@ -0,0 +1,256 @@
import userEvent from "@testing-library/user-event";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, afterEach, vi, it, expect } from "vitest";
import { ChatInput } from "#/components/features/chat/chat-input";
describe("ChatInput", () => {
const onSubmitMock = vi.fn();
afterEach(() => {
vi.clearAllMocks();
});
it("should render a textarea", () => {
render(<ChatInput onSubmit={onSubmitMock} />);
expect(screen.getByTestId("chat-input")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
it("should call onSubmit when the user types and presses enter", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
});
it("should call onSubmit when pressing the submit button", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
const button = screen.getByRole("button");
await user.type(textarea, "Hello, world!");
await user.click(button);
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!");
});
it("should not call onSubmit when the message is empty", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const button = screen.getByRole("button");
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should not call onSubmit when the message is only whitespace", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, " ");
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
await user.type(textarea, " \t\n");
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should disable submit", async () => {
const user = userEvent.setup();
render(<ChatInput disabled onSubmit={onSubmitMock} />);
const button = screen.getByRole("button");
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
expect(button).toBeDisabled();
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should render a placeholder with translation key", () => {
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
expect(textarea).toBeInTheDocument();
});
it("should create a newline instead of submitting when shift + enter is pressed", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Shift>} {Enter}"); // Shift + Enter
expect(onSubmitMock).not.toHaveBeenCalled();
// expect(textarea).toHaveValue("Hello, world!\n");
});
it("should clear the input message after sending a message", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
const button = screen.getByRole("button");
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
expect(textarea).toHaveValue("");
await user.type(textarea, "Hello, world!");
await user.click(button);
expect(textarea).toHaveValue("");
});
it("should hide the submit button", () => {
render(<ChatInput onSubmit={onSubmitMock} showButton={false} />);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should call onChange when the user types", async () => {
const user = userEvent.setup();
const onChangeMock = vi.fn();
render(<ChatInput onSubmit={onSubmitMock} onChange={onChangeMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Hello, world!");
expect(onChangeMock).toHaveBeenCalledTimes("Hello, world!".length);
});
it("should have set the passed value", () => {
render(<ChatInput value="Hello, world!" onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveValue("Hello, world!");
});
it("should display the stop button and trigger the callback", async () => {
const user = userEvent.setup();
const onStopMock = vi.fn();
render(
<ChatInput onSubmit={onSubmitMock} button="stop" onStop={onStopMock} />,
);
const stopButton = screen.getByTestId("stop-button");
await user.click(stopButton);
expect(onStopMock).toHaveBeenCalledOnce();
});
it("should call onFocus and onBlur when the textarea is focused and blurred", async () => {
const user = userEvent.setup();
const onFocusMock = vi.fn();
const onBlurMock = vi.fn();
render(
<ChatInput
onSubmit={onSubmitMock}
onFocus={onFocusMock}
onBlur={onBlurMock}
/>,
);
const textarea = screen.getByRole("textbox");
await user.click(textarea);
expect(onFocusMock).toHaveBeenCalledOnce();
await user.tab();
expect(onBlurMock).toHaveBeenCalledOnce();
});
it("should handle text paste correctly", () => {
const onSubmit = vi.fn();
const onChange = vi.fn();
render(<ChatInput onSubmit={onSubmit} onChange={onChange} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Fire paste event with text data
fireEvent.paste(input!, {
clipboardData: {
getData: (type: string) => (type === "text/plain" ? "test paste" : ""),
files: [],
},
});
});
it("should handle image paste correctly", () => {
const onSubmit = vi.fn();
const onFilesPaste = vi.fn();
render(<ChatInput onSubmit={onSubmit} onFilesPaste={onFilesPaste} />);
const input = screen.getByTestId("chat-input").querySelector("textarea");
expect(input).toBeTruthy();
// Create a paste event with an image file
const file = new File(["dummy content"], "image.png", {
type: "image/png",
});
// Fire paste event with image data
fireEvent.paste(input!, {
clipboardData: {
getData: () => "",
files: [file],
},
});
// Verify file paste was handled
expect(onFilesPaste).toHaveBeenCalledWith([file]);
});
it("should use the default maxRows value", () => {
// We can't directly test the maxRows prop as it's not exposed in the DOM
// Instead, we'll verify the component renders with the default props
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
expect(textarea).toBeInTheDocument();
// The actual verification of maxRows=16 is handled internally by the TextareaAutosize component
// and affects how many rows the textarea can expand to
});
it("should not submit when Enter is pressed during IME composition", async () => {
const user = userEvent.setup();
render(<ChatInput onSubmit={onSubmitMock} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "こんにちは");
// Simulate Enter during IME composition
fireEvent.keyDown(textarea, {
key: "Enter",
isComposing: true,
nativeEvent: { isComposing: true },
});
expect(onSubmitMock).not.toHaveBeenCalled();
// Simulate normal Enter after composition is done
fireEvent.keyDown(textarea, {
key: "Enter",
isComposing: false,
nativeEvent: { isComposing: false },
});
expect(onSubmitMock).toHaveBeenCalledWith("こんにちは");
});
});

View File

@@ -1,56 +1,14 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "test-utils";
import type { Message } from "#/message";
import { SUGGESTIONS } from "#/utils/suggestions";
import { ChatInterface } from "#/components/features/chat/chat-interface";
// Mock React Router hooks at the top level
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRouteLoaderData: vi.fn(() => ({})),
};
});
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
// Helper function to render with Router context
const renderChatInterfaceWithRouter = () =>
renderWithProviders(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const renderChatInterface = (messages: Message[]) =>
renderWithProviders(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
renderWithProviders(<ChatInterface />);
describe("Empty state", () => {
const { send: sendMock } = vi.hoisted(() => ({
@@ -66,6 +24,11 @@ describe("Empty state", () => {
}));
beforeAll(() => {
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({})),
}));
vi.mock("#/context/socket", async (importActual) => ({
...(await importActual<typeof import("#/context/ws-client-provider")>()),
useWsClient: useWsClientMock,
@@ -79,7 +42,7 @@ describe("Empty state", () => {
it.todo("should render suggestions if empty");
it("should render the default suggestions", () => {
renderChatInterfaceWithRouter();
renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("suggestions");
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
@@ -104,7 +67,7 @@ describe("Empty state", () => {
isLoadingMessages: false,
}));
const user = userEvent.setup();
renderChatInterfaceWithRouter();
renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
@@ -127,7 +90,7 @@ describe("Empty state", () => {
isLoadingMessages: false,
}));
const user = userEvent.setup();
const { rerender } = renderChatInterfaceWithRouter();
const { rerender } = renderWithProviders(<ChatInterface />);
const suggestions = screen.getByTestId("suggestions");
const displayedSuggestions = within(suggestions).getAllByRole("button");
@@ -140,11 +103,7 @@ describe("Empty state", () => {
status: "CONNECTED",
isLoadingMessages: false,
}));
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
await waitFor(() =>
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
@@ -234,11 +193,7 @@ describe.skip("ChatInterface", () => {
},
];
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
const imageCarousel = screen.getByTestId("image-carousel");
expect(imageCarousel).toBeInTheDocument();
@@ -277,11 +232,7 @@ describe.skip("ChatInterface", () => {
pending: true,
});
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
expect(screen.getByTestId("continue-action-button")).toBeInTheDocument();
});
@@ -309,7 +260,10 @@ describe.skip("ChatInterface", () => {
});
it("should render both GitHub buttons initially when ghToken is available", () => {
// Note: This test may need adjustment since useRouteLoaderData is now globally mocked
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
@@ -332,7 +286,10 @@ describe.skip("ChatInterface", () => {
});
it("should render only 'Push changes to PR' button after PR is created", async () => {
// Note: This test may need adjustment since useRouteLoaderData is now globally mocked
vi.mock("react-router", async (importActual) => ({
...(await importActual<typeof import("react-router")>()),
useRouteLoaderData: vi.fn(() => ({ ghToken: "test-token" })),
}));
const messages: Message[] = [
{
@@ -351,11 +308,7 @@ describe.skip("ChatInterface", () => {
await user.click(prButton);
// Re-render to trigger state update
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
// Verify only one button is shown
const pushToPrButton = screen.getByRole("button", {
@@ -405,11 +358,7 @@ describe.skip("ChatInterface", () => {
pending: true,
});
rerender(
<MemoryRouter>
<ChatInterface />
</MemoryRouter>,
);
rerender(<ChatInterface />);
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
});

View File

@@ -2,8 +2,6 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, test, vi } from "vitest";
import { AccountSettingsContextMenu } from "#/components/features/context-menu/account-settings-context-menu";
import { MemoryRouter } from "react-router";
import { renderWithProviders } from "../../../test-utils";
describe("AccountSettingsContextMenu", () => {
const user = userEvent.setup();
@@ -11,11 +9,6 @@ describe("AccountSettingsContextMenu", () => {
const onLogoutMock = vi.fn();
const onCloseMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: React.ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
};
afterEach(() => {
onClickAccountSettingsMock.mockClear();
onLogoutMock.mockClear();
@@ -23,7 +16,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should always render the right options", () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -37,7 +30,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should call onLogout when the logout option is clicked", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -51,7 +44,7 @@ describe("AccountSettingsContextMenu", () => {
});
test("logout button is always enabled", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}
@@ -65,7 +58,7 @@ describe("AccountSettingsContextMenu", () => {
});
it("should call onClose when clicking outside of the element", async () => {
renderWithRouter(
render(
<AccountSettingsContextMenu
onLogout={onLogoutMock}
onClose={onCloseMock}

View File

@@ -12,7 +12,7 @@ import {
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "test-utils";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card/conversation-card";
import { ConversationCard } from "#/components/features/conversation-panel/conversation-card";
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
@@ -76,6 +76,7 @@ describe("ConversationCard", () => {
within(card).getByText("Conversation 1");
// Just check that the card contains the expected text content
expect(card).toHaveTextContent("Created");
expect(card).toHaveTextContent("ago");
// Use a regex to match the time part since it might have whitespace
@@ -260,9 +261,10 @@ describe("ConversationCard", () => {
await user.tab();
expect(onChangeTitle).toHaveBeenCalledWith("New Conversation Name");
expect(title).toHaveValue("New Conversation Name");
});
it("should not call onChange title", async () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
@@ -285,7 +287,8 @@ describe("ConversationCard", () => {
await user.clear(title);
await user.tab();
expect(onChangeTitle).not.toBeCalled();
expect(onChangeTitle).not.toHaveBeenCalled();
expect(title).toHaveValue("Conversation 1");
});
test("clicking the title should trigger the onClick handler", async () => {
@@ -496,4 +499,38 @@ describe("ConversationCard", () => {
expect(screen.queryByTestId("ellipsis-button")).not.toBeInTheDocument();
});
describe("state indicator", () => {
it("should render the 'STOPPED' indicator by default", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
screen.getByTestId("STOPPED-indicator");
});
it("should render the other indicators when provided", () => {
renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
conversationStatus="RUNNING"
/>,
);
expect(screen.queryByTestId("STOPPED-indicator")).not.toBeInTheDocument();
screen.getByTestId("RUNNING-indicator");
});
});
});

View File

@@ -563,7 +563,7 @@ describe("ConversationPanel", () => {
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$RENAME");
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
@@ -682,6 +682,9 @@ describe("ConversationPanel", () => {
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
@@ -708,6 +711,9 @@ describe("ConversationPanel", () => {
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
@@ -788,11 +794,11 @@ describe("ConversationPanel", () => {
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
// Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).not.toHaveBeenCalledWith("1", {
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});

View File

@@ -1,573 +0,0 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ConversationName } from "#/components/features/conversation/conversation-name";
import { ConversationNameContextMenu } from "#/components/features/conversation/conversation-name-context-menu";
import { BrowserRouter } from "react-router";
// Mock the hooks and utilities
const mockMutate = vi.fn();
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: {
conversation_id: "test-conversation-id",
title: "Test Conversation",
status: "RUNNING",
},
}),
}));
vi.mock("#/hooks/mutation/use-update-conversation", () => ({
useUpdateConversation: () => ({
mutate: mockMutate,
}),
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
CONVERSATION$TITLE_UPDATED: "Conversation title updated",
BUTTON$RENAME: "Rename",
BUTTON$EXPORT_CONVERSATION: "Export Conversation",
BUTTON$DOWNLOAD_VIA_VSCODE: "Download via VS Code",
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA: "Show Agent Tools",
CONVERSATION$SHOW_MICROAGENTS: "Show Microagents",
BUTTON$DISPLAY_COST: "Display Cost",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation (Stop Runtime)",
COMMON$DELETE_CONVERSATION: "Delete Conversation",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
// Helper function to render ConversationName with Router context
const renderConversationNameWithRouter = () => {
return renderWithProviders(
<BrowserRouter>
<ConversationName />
</BrowserRouter>,
);
};
describe("ConversationName", () => {
beforeAll(() => {
vi.stubGlobal("window", {
open: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render the conversation name in view mode", () => {
renderConversationNameWithRouter();
const container = screen.getByTestId("conversation-name");
const titleElement = within(container).getByTestId(
"conversation-name-title",
);
expect(container).toBeInTheDocument();
expect(titleElement).toBeInTheDocument();
expect(titleElement).toHaveTextContent("Test Conversation");
});
it("should switch to edit mode on double click", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
// Initially should be in view mode
expect(titleElement).toBeInTheDocument();
expect(
screen.queryByTestId("conversation-name-input"),
).not.toBeInTheDocument();
// Double click to enter edit mode
await user.dblClick(titleElement);
// Should now be in edit mode
expect(
screen.queryByTestId("conversation-name-title"),
).not.toBeInTheDocument();
const inputElement = screen.getByTestId("conversation-name-input");
expect(inputElement).toBeInTheDocument();
expect(inputElement).toHaveValue("Test Conversation");
});
it("should update conversation title when input loses focus with valid value", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, "New Conversation Title");
await user.tab(); // Trigger blur event
// Verify that the update function was called
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "test-conversation-id",
newTitle: "New Conversation Title",
},
expect.any(Object),
);
});
it("should not update conversation when title is unchanged", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
// Keep the same title
await user.tab();
// Should still have the original title
expect(inputElement).toHaveValue("Test Conversation");
});
it("should not call the API if user attempts to save an unchanged title", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
// Verify the input has the original title
expect(inputElement).toHaveValue("Test Conversation");
// Trigger blur without changing the title
await user.tab();
// Verify that the API was NOT called
expect(mockMutate).not.toHaveBeenCalled();
});
it("should reset input value when title is empty and blur", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.tab();
// Should reset to original title
expect(inputElement).toHaveValue("Test Conversation");
});
it("should trim whitespace from input value", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, " Trimmed Title ");
await user.tab();
// Should call mutation with trimmed value
expect(mockMutate).toHaveBeenCalledWith(
{
conversationId: "test-conversation-id",
newTitle: "Trimmed Title",
},
expect.any(Object),
);
});
it("should handle Enter key to save changes", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
await user.clear(inputElement);
await user.type(inputElement, "New Title");
await user.keyboard("{Enter}");
// Should have the new title
expect(inputElement).toHaveValue("New Title");
});
it("should prevent event propagation when clicking input in edit mode", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
const clickEvent = new MouseEvent("click", { bubbles: true });
const preventDefaultSpy = vi.spyOn(clickEvent, "preventDefault");
const stopPropagationSpy = vi.spyOn(clickEvent, "stopPropagation");
inputElement.dispatchEvent(clickEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
expect(stopPropagationSpy).toHaveBeenCalled();
});
it("should return to view mode after blur", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
// Should be in edit mode
expect(screen.getByTestId("conversation-name-input")).toBeInTheDocument();
await user.tab();
// Should be back in view mode
expect(screen.getByTestId("conversation-name-title")).toBeInTheDocument();
expect(
screen.queryByTestId("conversation-name-input"),
).not.toBeInTheDocument();
});
it("should focus input when entering edit mode", async () => {
const user = userEvent.setup();
renderConversationNameWithRouter();
const titleElement = screen.getByTestId("conversation-name-title");
await user.dblClick(titleElement);
const inputElement = screen.getByTestId("conversation-name-input");
expect(inputElement).toHaveFocus();
});
});
describe("ConversationNameContextMenu", () => {
const defaultProps = {
onClose: vi.fn(),
};
afterEach(() => {
vi.clearAllMocks();
});
it("should render all menu options when all handlers are provided", () => {
const handlers = {
onRename: vi.fn(),
onDelete: vi.fn(),
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
expect(screen.getByTestId("rename-button")).toBeInTheDocument();
expect(screen.getByTestId("delete-button")).toBeInTheDocument();
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
expect(screen.getByTestId("display-cost-button")).toBeInTheDocument();
expect(screen.getByTestId("show-agent-tools-button")).toBeInTheDocument();
expect(screen.getByTestId("show-microagents-button")).toBeInTheDocument();
expect(
screen.getByTestId("export-conversation-button"),
).toBeInTheDocument();
expect(screen.getByTestId("download-vscode-button")).toBeInTheDocument();
});
it("should not render menu options when handlers are not provided", () => {
renderWithProviders(<ConversationNameContextMenu {...defaultProps} />);
expect(screen.queryByTestId("rename-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("display-cost-button")).not.toBeInTheDocument();
expect(
screen.queryByTestId("show-agent-tools-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("show-microagents-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("export-conversation-button"),
).not.toBeInTheDocument();
expect(
screen.queryByTestId("download-vscode-button"),
).not.toBeInTheDocument();
});
it("should call rename handler when rename button is clicked", async () => {
const user = userEvent.setup();
const onRename = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onRename={onRename} />,
);
const renameButton = screen.getByTestId("rename-button");
await user.click(renameButton);
expect(onRename).toHaveBeenCalledTimes(1);
});
it("should call delete handler when delete button is clicked", async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onDelete={onDelete} />,
);
const deleteButton = screen.getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalledTimes(1);
});
it("should call stop handler when stop button is clicked", async () => {
const user = userEvent.setup();
const onStop = vi.fn();
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} onStop={onStop} />,
);
const stopButton = screen.getByTestId("stop-button");
await user.click(stopButton);
expect(onStop).toHaveBeenCalledTimes(1);
});
it("should call display cost handler when display cost button is clicked", async () => {
const user = userEvent.setup();
const onDisplayCost = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onDisplayCost={onDisplayCost}
/>,
);
const displayCostButton = screen.getByTestId("display-cost-button");
await user.click(displayCostButton);
expect(onDisplayCost).toHaveBeenCalledTimes(1);
});
it("should call show agent tools handler when show agent tools button is clicked", async () => {
const user = userEvent.setup();
const onShowAgentTools = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onShowAgentTools={onShowAgentTools}
/>,
);
const showAgentToolsButton = screen.getByTestId("show-agent-tools-button");
await user.click(showAgentToolsButton);
expect(onShowAgentTools).toHaveBeenCalledTimes(1);
});
it("should call show microagents handler when show microagents button is clicked", async () => {
const user = userEvent.setup();
const onShowMicroagents = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onShowMicroagents={onShowMicroagents}
/>,
);
const showMicroagentsButton = screen.getByTestId("show-microagents-button");
await user.click(showMicroagentsButton);
expect(onShowMicroagents).toHaveBeenCalledTimes(1);
});
it("should call export conversation handler when export conversation button is clicked", async () => {
const user = userEvent.setup();
const onExportConversation = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onExportConversation={onExportConversation}
/>,
);
const exportButton = screen.getByTestId("export-conversation-button");
await user.click(exportButton);
expect(onExportConversation).toHaveBeenCalledTimes(1);
});
it("should call download via VSCode handler when download via VSCode button is clicked", async () => {
const user = userEvent.setup();
const onDownloadViaVSCode = vi.fn();
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onDownloadViaVSCode={onDownloadViaVSCode}
/>,
);
const downloadButton = screen.getByTestId("download-vscode-button");
await user.click(downloadButton);
expect(onDownloadViaVSCode).toHaveBeenCalledTimes(1);
});
it("should render separators between logical groups", () => {
const handlers = {
onRename: vi.fn(),
onShowAgentTools: vi.fn(),
onExportConversation: vi.fn(),
onDisplayCost: vi.fn(),
onStop: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
// Look for separator elements using test IDs
expect(screen.getByTestId("separator-tools")).toBeInTheDocument();
expect(screen.getByTestId("separator-export")).toBeInTheDocument();
expect(screen.getByTestId("separator-info-control")).toBeInTheDocument();
});
it("should apply correct positioning class when position is top", () => {
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
{...handlers}
position="top"
/>,
);
const contextMenu = screen.getByTestId("conversation-name-context-menu");
expect(contextMenu).toHaveClass("bottom-full");
});
it("should apply correct positioning class when position is bottom", () => {
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
{...handlers}
position="bottom"
/>,
);
const contextMenu = screen.getByTestId("conversation-name-context-menu");
expect(contextMenu).toHaveClass("top-full");
});
it("should render correct text content for each menu option", () => {
const handlers = {
onRename: vi.fn(),
onDelete: vi.fn(),
onStop: vi.fn(),
onDisplayCost: vi.fn(),
onShowAgentTools: vi.fn(),
onShowMicroagents: vi.fn(),
onExportConversation: vi.fn(),
onDownloadViaVSCode: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu {...defaultProps} {...handlers} />,
);
expect(screen.getByTestId("rename-button")).toHaveTextContent("Rename");
expect(screen.getByTestId("delete-button")).toHaveTextContent(
"Delete Conversation",
);
expect(screen.getByTestId("stop-button")).toHaveTextContent(
"Close Conversation (Stop Runtime)",
);
expect(screen.getByTestId("display-cost-button")).toHaveTextContent(
"Display Cost",
);
expect(screen.getByTestId("show-agent-tools-button")).toHaveTextContent(
"Show Agent Tools",
);
expect(screen.getByTestId("show-microagents-button")).toHaveTextContent(
"Show Microagents",
);
expect(screen.getByTestId("export-conversation-button")).toHaveTextContent(
"Export Conversation",
);
expect(screen.getByTestId("download-vscode-button")).toHaveTextContent(
"Download via VS Code",
);
});
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
const handlers = {
onRename: vi.fn(),
};
renderWithProviders(
<ConversationNameContextMenu
{...defaultProps}
onClose={onClose}
{...handlers}
/>,
);
// The onClose is typically called by the parent component when clicking outside
// This test verifies the prop is properly passed
expect(onClose).toBeDefined();
});
});

View File

@@ -1,395 +0,0 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { ServerStatus } from "#/components/features/controls/server-status";
import { ServerStatusContextMenu } from "#/components/features/controls/server-status-context-menu";
import { ConversationStatus } from "#/types/conversation-status";
import { AgentState } from "#/types/agent-state";
// Mock the conversation slice actions
vi.mock("#/state/conversation-slice", () => ({
setShouldStopConversation: vi.fn(),
setShouldStartConversation: vi.fn(),
default: {
name: "conversation",
initialState: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
},
reducers: {},
},
}));
// Mock react-redux
vi.mock("react-redux", () => ({
useSelector: vi.fn((selector) => {
// Mock the selector to return different agent states based on test needs
return {
curAgentState: AgentState.RUNNING,
};
}),
Provider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock the custom hooks
const mockStartConversationMutate = vi.fn();
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
useStartConversation: () => ({
mutate: mockStartConversationMutate,
}),
}));
// Mock the useConversationNameContextMenu hook
const mockHandleStop = vi.fn();
const mockHandleConfirmStop = vi.fn();
const mockSetConfirmStopModalVisible = vi.fn();
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
handleStop: mockHandleStop,
handleConfirmStop: mockHandleConfirmStop,
confirmStopModalVisible: false,
setConfirmStopModalVisible: mockSetConfirmStopModalVisible,
}),
}));
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({
conversationId: "test-conversation-id",
}),
}));
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
// Mock react-i18next
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
COMMON$RUNNING: "Running",
COMMON$SERVER_STOPPED: "Server Stopped",
COMMON$ERROR: "Error",
COMMON$STARTING: "Starting",
COMMON$CLOSE_CONVERSATION_STOP_RUNTIME:
"Close Conversation & Stop Runtime",
COMMON$START_CONVERSATION: "Start Conversation",
};
return translations[key] || key;
},
i18n: {
changeLanguage: () => new Promise(() => {}),
},
}),
};
});
describe("ServerStatus", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render server status with different conversation statuses", () => {
// Test RUNNING status
const { rerender } = renderWithProviders(
<ServerStatus conversationStatus="RUNNING" />,
);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test STOPPED status
rerender(<ServerStatus conversationStatus="STOPPED" />);
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
// Test STARTING status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus="STARTING" />);
expect(screen.getByText("Running")).toBeInTheDocument();
// Test null status (shows "Running" due to agent state being RUNNING)
rerender(<ServerStatus conversationStatus={null} />);
expect(screen.getByText("Running")).toBeInTheDocument();
});
it("should show context menu when clicked with RUNNING status", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
});
it("should show context menu when clicked with STOPPED status", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should appear
expect(
screen.getByTestId("server-status-context-menu"),
).toBeInTheDocument();
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
});
it("should not show context menu when clicked with other statuses", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
const statusContainer = screen.getByText("Running").closest("div");
expect(statusContainer).toBeInTheDocument();
await user.click(statusContainer!);
// Context menu should not appear
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should call handleStop when stop server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockHandleStop.mockClear();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should call start conversation mutation when start server is clicked", async () => {
const user = userEvent.setup();
// Clear previous calls
mockStartConversationMutate.mockClear();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(mockStartConversationMutate).toHaveBeenCalledWith({
conversationId: "test-conversation-id",
providers: [],
});
});
it("should close context menu after stop server action", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
const statusContainer = screen.getByText("Running").closest("div");
await user.click(statusContainer!);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
// Context menu should be closed (handled by handleStop)
expect(mockHandleStop).toHaveBeenCalledTimes(1);
});
it("should close context menu after start server action", async () => {
const user = userEvent.setup();
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
const statusContainer = screen.getByText("Server Stopped").closest("div");
await user.click(statusContainer!);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
// Context menu should be closed
expect(
screen.queryByTestId("server-status-context-menu"),
).not.toBeInTheDocument();
});
it("should handle null conversation status", () => {
renderWithProviders(<ServerStatus conversationStatus={null} />);
const statusText = screen.getByText("Running");
expect(statusText).toBeInTheDocument();
});
});
describe("ServerStatusContextMenu", () => {
const defaultProps = {
onClose: vi.fn(),
conversationStatus: "RUNNING" as ConversationStatus,
};
afterEach(() => {
vi.clearAllMocks();
});
it("should render stop server button when status is RUNNING", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
expect(
screen.getByText("Close Conversation & Stop Runtime"),
).toBeInTheDocument();
});
it("should render start server button when status is STOPPED", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={vi.fn()}
/>,
);
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
expect(screen.getByText("Start Conversation")).toBeInTheDocument();
});
it("should not render stop server button when onStopServer is not provided", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
/>,
);
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
});
it("should not render start server button when onStartServer is not provided", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
/>,
);
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
it("should call onStopServer when stop button is clicked", async () => {
const user = userEvent.setup();
const onStopServer = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={onStopServer}
/>,
);
const stopButton = screen.getByTestId("stop-server-button");
await user.click(stopButton);
expect(onStopServer).toHaveBeenCalledTimes(1);
});
it("should call onStartServer when start button is clicked", async () => {
const user = userEvent.setup();
const onStartServer = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={onStartServer}
/>,
);
const startButton = screen.getByTestId("start-server-button");
await user.click(startButton);
expect(onStartServer).toHaveBeenCalledTimes(1);
});
it("should render correct text content for stop server button", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
expect(screen.getByTestId("stop-server-button")).toHaveTextContent(
"Close Conversation & Stop Runtime",
);
});
it("should render correct text content for start server button", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STOPPED"
onStartServer={vi.fn()}
/>,
);
expect(screen.getByTestId("start-server-button")).toHaveTextContent(
"Start Conversation",
);
});
it("should call onClose when context menu is closed", () => {
const onClose = vi.fn();
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
onClose={onClose}
conversationStatus="RUNNING"
onStopServer={vi.fn()}
/>,
);
// The onClose is typically called by the parent component when clicking outside
// This test verifies the prop is properly passed
expect(onClose).toBeDefined();
});
it("should not render any buttons for other conversation statuses", () => {
renderWithProviders(
<ServerStatusContextMenu
{...defaultProps}
conversationStatus="STARTING"
/>,
);
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
});
});

View File

@@ -1,9 +1,12 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { HomeHeader } from "#/components/features/home/home-header";
import OpenHands from "#/api/open-hands";
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -15,6 +18,11 @@ vi.mock("react-i18next", async () => {
// Return a mock translation for the test
const translations: Record<string, string> = {
HOME$LETS_START_BUILDING: "Let's start building",
HOME$LAUNCH_FROM_SCRATCH: "Launch from Scratch",
HOME$LOADING: "Loading...",
HOME$OPENHANDS_DESCRIPTION: "OpenHands is an AI software engineer",
HOME$NOT_SURE_HOW_TO_START: "Not sure how to start?",
HOME$READ_THIS: "Read this",
};
return translations[key] || key;
},
@@ -24,7 +32,18 @@ vi.mock("react-i18next", async () => {
});
const renderHomeHeader = () => {
return render(<HomeHeader />, {
const RouterStub = createRoutesStub([
{
Component: HomeHeader,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
@@ -36,25 +55,39 @@ const renderHomeHeader = () => {
};
describe("HomeHeader", () => {
it("should render the header with the correct title", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderHomeHeader();
const title = screen.getByText("Let's start building");
expect(title).toBeInTheDocument();
const launchButton = screen.getByRole("button", {
name: /Launch from Scratch/i,
});
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should render the GuideMessage component", () => {
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderHomeHeader();
// The GuideMessage component should be rendered as part of the header
const header = screen.getByRole("banner");
expect(header).toBeInTheDocument();
});
const launchButton = screen.getByRole("button", {
name: /Launch from Scratch/i,
});
await userEvent.click(launchButton);
it("should have the correct CSS classes for layout", () => {
renderHomeHeader();
const header = screen.getByRole("banner");
expect(header).toHaveClass("flex", "flex-col", "items-center");
expect(launchButton).toHaveTextContent(/Loading.../i);
expect(launchButton).toBeDisabled();
});
});

View File

@@ -1,87 +0,0 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { NewConversation } from "#/components/features/home/new-conversation";
import OpenHands from "#/api/open-hands";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
COMMON$START_FROM_SCRATCH: "Start from Scratch",
HOME$NEW_PROJECT_DESCRIPTION: "Create a new project from scratch",
COMMON$NEW_CONVERSATION: "New Conversation",
HOME$LOADING: "Loading...",
};
return translations[key] || key;
},
i18n: { language: "en" },
}),
};
});
const renderNewConversation = () => {
const RouterStub = createRoutesStub([
{
Component: NewConversation,
path: "/",
},
{
Component: () => <div data-testid="conversation-screen" />,
path: "/conversations/:conversationId",
},
]);
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</Provider>
),
});
};
describe("NewConversation", () => {
it("should create an empty conversation and redirect when pressing the launch from scratch button", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
);
// expect to be redirected to /conversations/:conversationId
await screen.findByTestId("conversation-screen");
});
it("should change the launch button text to 'Loading...' when creating a conversation", async () => {
renderNewConversation();
const launchButton = screen.getByTestId("launch-new-conversation-button");
await userEvent.click(launchButton);
expect(launchButton).toHaveTextContent(/Loading.../i);
expect(launchButton).toBeDisabled();
});
});

View File

@@ -54,14 +54,12 @@ 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",
},
];
@@ -101,15 +99,16 @@ describe("RepoConnector", () => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then interact with the repository dropdown
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -135,23 +134,23 @@ describe("RepoConnector", () => {
expect(launchButton).toBeDisabled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ 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.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -162,8 +161,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
expect(launchButton).toBeEnabled();
@@ -226,19 +224,6 @@ 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",
@@ -259,23 +244,23 @@ describe("RepoConnector", () => {
expect(createConversationSpy).not.toHaveBeenCalled();
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ 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.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
within(repoConnector).getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
within(repoConnector).getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -286,8 +271,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
@@ -304,8 +288,6 @@ 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",
@@ -316,10 +298,10 @@ describe("RepoConnector", () => {
});
// Mock the repository branches API call
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue({ branches: [
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ 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();
@@ -327,16 +309,16 @@ describe("RepoConnector", () => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = await waitFor(() =>
screen.getByTestId("git-repo-dropdown"),
const repoDropdown = await waitFor(() =>
screen.getByTestId("repo-dropdown"),
);
const repoInput = within(repoDropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
@@ -347,8 +329,7 @@ describe("RepoConnector", () => {
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
await userEvent.click(launchButton);
@@ -377,7 +358,7 @@ describe("RepoConnector", () => {
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",
);
const dropdown = screen.queryByTestId("git-repo-dropdown");
const dropdown = screen.queryByTestId("repo-dropdown");
const launchButton = screen.queryByTestId("repo-launch-button");
const providerLinks = screen.queryAllByText(/add git(hub|lab) repos/i);

View File

@@ -1,6 +1,7 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, vi, beforeEach, it } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { RepositorySelectionForm } from "../../../../src/components/features/home/repo-selection-form";
import OpenHands from "#/api/open-hands";
import { GitRepository } from "#/types/git";
@@ -13,7 +14,6 @@ const mockUseTranslation = vi.fn();
const mockUseAuth = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseUserProviders = vi.fn();
const mockUseSearchRepositories = vi.fn();
// Setup default mock returns
mockUseUserRepositories.mockReturnValue({
@@ -55,12 +55,6 @@ mockUseUserProviders.mockReturnValue({
providers: ["github"],
});
// Default mock for useSearchRepositories
mockUseSearchRepositories.mockReturnValue({
data: [],
isLoading: false,
});
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
@@ -93,19 +87,8 @@ vi.mock("#/context/auth-context", () => ({
useAuth: () => mockUseAuth(),
}));
// Mock debounce to simulate proper debounced behavior
let debouncedValue = "";
vi.mock("#/hooks/use-debounce", () => ({
useDebounce: (value: string, _delay: number) => {
// In real debouncing, only the final value after the delay should be returned
// For testing, we'll return the full value once it's complete
if (value && value.length > 20) {
// URL is long enough
debouncedValue = value;
return value;
}
return debouncedValue; // Return previous debounced value for intermediate states
},
useDebounce: (value: string) => value,
}));
vi.mock("react-router", async (importActual) => ({
@@ -117,11 +100,6 @@ vi.mock("#/hooks/query/use-git-repositories", () => ({
useGitRepositories: () => mockUseGitRepositories(),
}));
vi.mock("#/hooks/query/use-search-repositories", () => ({
useSearchRepositories: (query: string, provider: string) =>
mockUseSearchRepositories(query, provider),
}));
const mockOnRepoSelection = vi.fn();
const renderForm = () =>
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
@@ -173,7 +151,7 @@ describe("RepositorySelectionForm", () => {
});
renderForm();
expect(await screen.findByTestId("git-repo-dropdown")).toBeInTheDocument();
expect(await screen.findByTestId("repo-dropdown")).toBeInTheDocument();
});
it("shows error message when repository fetch fails", async () => {
@@ -189,11 +167,30 @@ describe("RepositorySelectionForm", () => {
renderForm();
expect(await screen.findByTestId("dropdown-error")).toBeInTheDocument();
expect(screen.getByText("Failed to load data")).toBeInTheDocument();
expect(
await screen.findByTestId("repo-dropdown-error"),
).toBeInTheDocument();
expect(
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
).toBeInTheDocument();
});
it("should call the search repos API when searching a URL", async () => {
const MOCK_REPOS: GitRepository[] = [
{
id: "1",
full_name: "user/repo1",
git_provider: "github",
is_public: true,
},
{
id: "2",
full_name: "user/repo2",
git_provider: "github",
is_public: true,
},
];
const MOCK_SEARCH_REPOS: GitRepository[] = [
{
id: "3",
@@ -203,12 +200,11 @@ describe("RepositorySelectionForm", () => {
},
];
// Create a spy on the API call
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [] },
data: { pages: [{ data: MOCK_REPOS }] },
isLoading: false,
isError: false,
hasNextPage: false,
@@ -217,19 +213,36 @@ describe("RepositorySelectionForm", () => {
onLoadMore: vi.fn(),
});
// Mock search repositories hook to return our mock data
mockUseSearchRepositories.mockReturnValue({
data: MOCK_SEARCH_REPOS,
mockUseAuth.mockReturnValue({
isAuthenticated: true,
isLoading: false,
providersAreSet: true,
user: {
id: 1,
login: "testuser",
avatar_url: "https://example.com/avatar.png",
name: "Test User",
email: "test@example.com",
company: "Test Company",
},
login: vi.fn(),
logout: vi.fn(),
});
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector(
'input[type="text"]',
) as HTMLInputElement;
expect(input).toBeInTheDocument();
// The test should verify that typing a URL triggers the search behavior
// Since the component uses useSearchRepositories hook, just verify the hook is set up correctly
expect(mockUseSearchRepositories).toHaveBeenCalled();
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
"github",
);
});
it("should call onRepoSelection when a searched repository is selected", async () => {
@@ -242,6 +255,9 @@ describe("RepositorySelectionForm", () => {
},
];
const searchGitReposSpy = vi.spyOn(OpenHands, "searchGitRepositories");
searchGitReposSpy.mockResolvedValue(MOCK_SEARCH_REPOS);
mockUseGitRepositories.mockReturnValue({
data: { pages: [{ data: MOCK_SEARCH_REPOS }] },
isLoading: false,
@@ -252,21 +268,19 @@ describe("RepositorySelectionForm", () => {
onLoadMore: vi.fn(),
});
// Mock search repositories hook to return our mock data
mockUseSearchRepositories.mockReturnValue({
data: MOCK_SEARCH_REPOS,
isLoading: false,
});
renderForm();
const input = await screen.findByTestId("git-repo-dropdown");
const dropdown = await screen.findByTestId("repo-dropdown");
const input = dropdown.querySelector(
'input[type="text"]',
) as HTMLInputElement;
expect(input).toBeInTheDocument();
// Verify that the onRepoSelection callback prop was provided
expect(mockOnRepoSelection).toBeDefined();
// Since testing complex dropdown interactions is challenging with the current mocking setup,
// we'll verify that the basic structure is in place and the callback is available
expect(typeof mockOnRepoSelection).toBe("function");
await userEvent.type(input, "https://github.com/kubernetes/kubernetes");
expect(searchGitReposSpy).toHaveBeenLastCalledWith(
"kubernetes/kubernetes",
3,
"github",
);
});
});

View File

@@ -5,10 +5,10 @@ import userEvent from "@testing-library/user-event";
import { Provider } from "react-redux";
import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import OpenHands from "#/api/open-hands";
import { TaskCard } from "#/components/features/home/tasks/task-card";
import { GitRepository } from "#/types/git";
import { SuggestedTask } from "#/utils/types";
const MOCK_TASK_1: SuggestedTask = {
issue_number: 123,
@@ -73,10 +73,7 @@ describe("TaskCard", () => {
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
retrieveUserGitRepositoriesSpy.mockResolvedValue({ data: MOCK_RESPOSITORIES, nextPage: null });
});
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
@@ -128,7 +125,7 @@ describe("TaskCard", () => {
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
session_api_key: null
});
renderTaskCard();

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Provider } from "react-redux";
@@ -7,6 +7,7 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import userEvent from "@testing-library/user-event";
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -22,14 +23,6 @@ vi.mock("react-i18next", async () => {
};
});
// Mock the useIsAuthed hook to return authenticated
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({
data: true,
isLoading: false,
}),
}));
const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([
{
@@ -83,9 +76,9 @@ describe("TaskSuggestions", () => {
renderTaskSuggestions();
await waitFor(() => {
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
screen.getByText("octocat/hello-world");
screen.getByText("octocat/earth");
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
});
@@ -94,11 +87,9 @@ describe("TaskSuggestions", () => {
renderTaskSuggestions();
await waitFor(() => {
// Only check for the first 3 tasks that are actually rendered
// The component limits to 3 tasks due to getLimitedTaskGroups function
screen.getByText("Fix merge conflicts"); // First task from octocat/hello-world
screen.getByText("Fix broken CI checks"); // First task from octocat/earth
screen.getByText("Fix issue"); // Second task from octocat/earth
MOCK_TASKS.forEach((task) => {
screen.getByText(task.title);
});
});
});
@@ -110,11 +101,33 @@ describe("TaskSuggestions", () => {
expect(skeletons.length).toBeGreaterThan(0);
await waitFor(() => {
// Check for repository names (grouped by repo) - only the first 3 tasks are shown
screen.getByText("octocat/hello-world");
screen.getByText("octocat/earth");
MOCK_TASKS.forEach((taskGroup) => {
screen.getByText(taskGroup.title);
});
});
expect(screen.queryByTestId("task-group-skeleton")).not.toBeInTheDocument();
});
it("should render the tooltip button", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toBeInTheDocument();
});
it("should have the correct aria-label", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
expect(tooltipButton).toHaveAttribute(
"aria-label",
"TASKS$TASK_SUGGESTIONS_INFO",
);
});
it("should render the info icon", () => {
renderTaskSuggestions();
const tooltipButton = screen.getByTestId("task-suggestions-info");
const icon = tooltipButton.querySelector("svg");
expect(icon).toBeInTheDocument();
});
});

View File

@@ -89,4 +89,23 @@ describe("MaintenanceBanner", () => {
expect(screen.queryByTestId("maintenance-banner")).toBeInTheDocument();
});
it("banner doesn't reappear after dismissing on next maintenance event(past time)", () => {
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
const nextStartTime = "2023-01-15T10:00:00-05:00"; // EST timestamp
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
// Check if the banner is rendered
const banner = screen.queryByTestId("maintenance-banner");
const button = within(banner!).queryByTestId("dismiss-button");
act(() => {
fireEvent.click(button!);
});
expect(banner).not.toBeInTheDocument();
rerender(<MaintenanceBanner startTime={nextStartTime} />);
expect(screen.queryByTestId("maintenance-banner")).not.toBeInTheDocument();
});
});

View File

@@ -8,6 +8,7 @@ describe("TrajectoryActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -18,12 +19,14 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
@@ -31,6 +34,7 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -45,6 +49,7 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -54,12 +59,48 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when export button is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
describe("SaaS mode", () => {
it("should only render export button when isSaasMode is true", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const actions = screen.getByTestId("feedback-actions");
// Should not render feedback buttons in SaaS mode
expect(within(actions).queryByTestId("positive-feedback")).toBeNull();
expect(within(actions).queryByTestId("negative-feedback")).toBeNull();
// Should still render export button
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is false", () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={false}
/>,
);
@@ -67,6 +108,7 @@ describe("TrajectoryActions", () => {
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should render all buttons when isSaasMode is undefined (default behavior)", () => {
@@ -74,12 +116,30 @@ describe("TrajectoryActions", () => {
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const actions = screen.getByTestId("feedback-actions");
within(actions).getByTestId("positive-feedback");
within(actions).getByTestId("negative-feedback");
within(actions).getByTestId("export-trajectory");
});
it("should call onExportTrajectory when export button is clicked in SaaS mode", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
isSaasMode={true}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});
});

View File

@@ -1,62 +1,12 @@
import { screen } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: { status: null },
isFetched: true,
refetch: vi.fn(),
}),
}));
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
const onStopMock = vi.fn();
// Helper function to render with Router context
const renderInteractiveChatBox = (props: any, options: any = {}) => {
return renderWithProviders(
<MemoryRouter>
<InteractiveChatBox {...props} />
</MemoryRouter>,
options,
);
};
beforeAll(() => {
global.URL.createObjectURL = vi
.fn()
@@ -68,221 +18,111 @@ describe("InteractiveChatBox", () => {
});
it("should render", () => {
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const chatBox = screen.getByTestId("interactive-chat-box");
within(chatBox).getByTestId("chat-input");
within(chatBox).getByTestId("upload-image-input");
});
it.fails("should set custom values", () => {
render(
<InteractiveChatBox
onSubmit={onSubmitMock}
onStop={onStopMock}
value="Hello, world!"
/>,
);
const chatBox = screen.getByTestId("interactive-chat-box");
expect(chatBox).toBeInTheDocument();
});
const chatInput = within(chatBox).getByTestId("chat-input");
it("should set custom values", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
);
const textbox = screen.getByTestId("chat-input");
// Simulate user typing to populate the input
await user.type(textbox, "Hello, world!");
expect(textbox).toHaveTextContent("Hello, world!");
expect(chatInput).toHaveValue("Hello, world!");
});
it("should display the image previews when images are uploaded", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
// Create a larger file to ensure it passes validation
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
const file = new File([fileContent], "chucknorris.png", {
type: "image/png",
});
// Click on the paperclip icon to trigger file selection
const paperclipIcon = screen.getByTestId("paperclip-icon");
await user.click(paperclipIcon);
// Now trigger the file input change event directly
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// For now, just verify the file input is accessible
expect(input).toBeInTheDocument();
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
await user.upload(input, file);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
const files = [
new File(["(⌐□_□)"], "chucknorris2.png", { type: "image/png" }),
new File(["(⌐□_□)"], "chucknorris3.png", { type: "image/png" }),
];
await user.upload(input, files);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(3);
});
it("should remove the image preview when the close button is clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
);
const fileContent = new Array(1024).fill("a").join(""); // 1KB file
const file = new File([fileContent], "chucknorris.png", {
type: "image/png",
});
// Click on the paperclip icon to trigger file selection
const paperclipIcon = screen.getByTestId("paperclip-icon");
await user.click(paperclipIcon);
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// For now, just verify the file input is accessible
expect(input).toBeInTheDocument();
await user.upload(input, file);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(1);
const imagePreview = screen.getByTestId("image-preview");
const closeButton = within(imagePreview).getByRole("button");
await user.click(closeButton);
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
});
it("should call onSubmit with the message and images", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.INIT,
},
},
},
render(<InteractiveChatBox onSubmit={onSubmitMock} onStop={onStopMock} />);
const textarea = within(screen.getByTestId("chat-input")).getByRole(
"textbox",
);
const input = screen.getByTestId("upload-image-input");
const file = new File(["(⌐□_□)"], "chucknorris.png", { type: "image/png" });
const textarea = screen.getByTestId("chat-input");
// Type the message and ensure it's properly set
await user.upload(input, file);
await user.type(textarea, "Hello, world!");
await user.keyboard("{Enter}");
// Set innerText directly as the component reads this property
textarea.innerText = "Hello, world!";
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [file], []);
// Verify the text is in the input before submitting
expect(textarea).toHaveTextContent("Hello, world!");
// Click the submit button instead of pressing Enter for more reliable testing
const submitButton = screen.getByTestId("submit-button");
// Verify the button is enabled before clicking
expect(submitButton).not.toBeDisabled();
await user.click(submitButton);
expect(onSubmitMock).toHaveBeenCalledWith("Hello, world!", [], []);
// clear images after submission
expect(screen.queryAllByTestId("image-preview")).toHaveLength(0);
});
it("should disable the submit button when agent is loading", async () => {
it("should disable the submit button", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: false,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.LOADING,
},
},
},
render(
<InteractiveChatBox
isDisabled
onSubmit={onSubmitMock}
onStop={onStopMock}
/>,
);
const button = screen.getByTestId("submit-button");
const button = screen.getByRole("button");
expect(button).toBeDisabled();
await user.click(button);
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should display the stop button when agent is running and call onStop when clicked", async () => {
it("should display the stop button if set and call onStop when clicked", async () => {
const user = userEvent.setup();
renderInteractiveChatBox(
{
onSubmit: onSubmitMock,
onStop: onStopMock,
isWaitingForUserInput: false,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.RUNNING,
},
},
},
render(
<InteractiveChatBox
mode="stop"
onSubmit={onSubmitMock}
onStop={onStopMock}
/>,
);
const stopButton = screen.getByTestId("stop-button");
@@ -296,63 +136,55 @@ describe("InteractiveChatBox", () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onStop = vi.fn();
const onChange = vi.fn();
const { rerender } = renderInteractiveChatBox(
{
onSubmit: onSubmit,
onStop: onStop,
isWaitingForUserInput: true,
hasSubstantiveAgentActions: true,
optimisticUserMessage: false,
},
{
preloadedState: {
agent: {
curAgentState: AgentState.AWAITING_USER_INPUT,
},
conversation: {
isRightPanelShown: true,
shouldStopConversation: false,
shouldStartConversation: false,
images: [],
files: [],
loadingFiles: [],
loadingImages: [],
messageToSend: null,
shouldShownAgentLoading: false,
},
},
},
const { rerender } = render(
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
onChange={onChange}
value="test message"
/>,
);
// Verify text input has the initial value
const textarea = screen.getByTestId("chat-input");
expect(textarea).toHaveTextContent("");
// Upload an image via the upload button - this should NOT clear the text input
const file = new File(["dummy content"], "test.png", { type: "image/png" });
const input = screen.getByTestId("upload-image-input");
await user.upload(input, file);
// Set innerText directly as the component reads this property
textarea.innerText = "test message";
// Verify text input was not cleared
expect(screen.getByRole("textbox")).toHaveValue("test message");
expect(onChange).not.toHaveBeenCalledWith("");
// Submit the message
const submitButton = screen.getByTestId("submit-button");
// Submit the message with image
const submitButton = screen.getByRole("button", { name: "BUTTON$SEND" });
await user.click(submitButton);
// Verify onSubmit was called with the message
expect(onSubmit).toHaveBeenCalledWith("test message", [], []);
// Verify onSubmit was called with the message and image
expect(onSubmit).toHaveBeenCalledWith("test message", [file], []);
// Verify onChange was called to clear the text input
expect(onChange).toHaveBeenCalledWith("");
// Simulate parent component updating the value prop
rerender(
<MemoryRouter>
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
isWaitingForUserInput={true}
hasSubstantiveAgentActions={true}
optimisticUserMessage={false}
/>
</MemoryRouter>,
<InteractiveChatBox
onSubmit={onSubmit}
onStop={onStop}
onChange={onChange}
value=""
/>,
);
// Verify the text input was cleared
expect(screen.getByTestId("chat-input")).toHaveTextContent("");
expect(screen.getByRole("textbox")).toHaveValue("");
// Upload another image - this should NOT clear the text input
onChange.mockClear();
await user.upload(input, file);
// Verify text input is still empty and onChange was not called
expect(screen.getByRole("textbox")).toHaveValue("");
expect(onChange).not.toHaveBeenCalled();
});
});

View File

@@ -5,13 +5,7 @@ import translations from "../../src/i18n/translation.json";
import { UserAvatar } from "../../src/components/features/sidebar/user-avatar";
vi.mock("@heroui/react", () => ({
Tooltip: ({
content,
children,
}: {
content: string;
children: React.ReactNode;
}) => (
Tooltip: ({ content, children }: { content: string; children: React.ReactNode }) => (
<div>
{children}
<div>{content}</div>
@@ -19,33 +13,15 @@ vi.mock("@heroui/react", () => ({
),
}));
const supportedLanguages = [
"en",
"ja",
"zh-CN",
"zh-TW",
"ko-KR",
"de",
"no",
"it",
"pt",
"es",
"ar",
"fr",
"tr",
];
const supportedLanguages = ['en', 'ja', 'zh-CN', 'zh-TW', 'ko-KR', 'de', 'no', 'it', 'pt', 'es', 'ar', 'fr', 'tr'];
// Helper function to check if a translation exists for all supported languages
function checkTranslationExists(key: string) {
const missingTranslations: string[] = [];
const translationEntry = (
translations as Record<string, Record<string, string>>
)[key];
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
if (!translationEntry) {
throw new Error(
`Translation key "${key}" does not exist in translation.json`,
);
throw new Error(`Translation key "${key}" does not exist in translation.json`);
}
for (const lang of supportedLanguages) {
@@ -77,9 +53,7 @@ function findDuplicateKeys(obj: Record<string, any>) {
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translationEntry = (
translations as Record<string, Record<string, string>>
)[key];
const translationEntry = (translations as Record<string, Record<string, string>>)[key];
return translationEntry?.ja || key;
},
}),
@@ -128,13 +102,16 @@ describe("Landing page translations", () => {
// Check main content translations
expect(screen.getByText("開発を始めましょう!")).toBeInTheDocument();
expect(screen.getByText("VS Codeで開く")).toBeInTheDocument();
expect(
screen.getByText("テストカバレッジを向上させる"),
).toBeInTheDocument();
expect(screen.getByText("テストカバレッジを向上させる")).toBeInTheDocument();
expect(screen.getByText("Dependabot PRを自動マージ")).toBeInTheDocument();
expect(screen.getByText("READMEを改善")).toBeInTheDocument();
expect(screen.getByText("依存関係を整理")).toBeInTheDocument();
// Check user avatar tooltip
const userAvatar = screen.getByTestId("user-avatar");
userAvatar.focus();
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
// Check tab labels
const tabs = screen.getByTestId("tabs");
expect(tabs).toHaveTextContent("ターミナル");
@@ -143,12 +120,8 @@ describe("Landing page translations", () => {
expect(tabs).toHaveTextContent("コードエディタ");
// Check workspace label and new project button
expect(screen.getByTestId("workspace-label")).toHaveTextContent(
"ワークスペース",
);
expect(screen.getByTestId("new-project")).toHaveTextContent(
"新規プロジェクト",
);
expect(screen.getByTestId("workspace-label")).toHaveTextContent("ワークスペース");
expect(screen.getByTestId("new-project")).toHaveTextContent("新規プロジェクト");
// Check status messages
const status = screen.getByTestId("status");
@@ -156,6 +129,9 @@ describe("Landing page translations", () => {
expect(status).toHaveTextContent("接続済み");
expect(status).toHaveTextContent("サーバーに接続済み");
// Check account settings menu
expect(screen.getByText("アカウント設定")).toBeInTheDocument();
// Check time-related translations
const time = screen.getByTestId("time");
expect(time).toHaveTextContent("5 分前");
@@ -183,12 +159,12 @@ describe("Landing page translations", () => {
"STATUS$CONNECTED_TO_SERVER",
"TIME$MINUTES_AGO",
"TIME$HOURS_AGO",
"TIME$DAYS_AGO",
"TIME$DAYS_AGO"
];
// Check all keys and collect missing translations
const missingTranslationsMap = new Map<string, string[]>();
translationKeys.forEach((key) => {
translationKeys.forEach(key => {
const missing = checkTranslationExists(key);
if (missing.length > 0) {
missingTranslationsMap.set(key, missing);
@@ -198,11 +174,8 @@ describe("Landing page translations", () => {
// If any translations are missing, throw an error with all missing translations
if (missingTranslationsMap.size > 0) {
const errorMessage = Array.from(missingTranslationsMap.entries())
.map(
([key, langs]) =>
`\n- "${key}" is missing translations for: ${langs.join(", ")}`,
)
.join("");
.map(([key, langs]) => `\n- "${key}" is missing translations for: ${langs.join(', ')}`)
.join('');
throw new Error(`Missing translations:${errorMessage}`);
}
});
@@ -211,9 +184,7 @@ describe("Landing page translations", () => {
const duplicates = findDuplicateKeys(translations);
if (duplicates.length > 0) {
throw new Error(
`Found duplicate translation keys: ${duplicates.join(", ")}`,
);
throw new Error(`Found duplicate translation keys: ${duplicates.join(', ')}`);
}
});
});

View File

@@ -2,9 +2,8 @@ import { render, screen } from "@testing-library/react";
import { describe, expect, it, test, vi, afterEach, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { UserActions } from "#/components/features/sidebar/user-actions";
import { MemoryRouter } from "react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactElement } from "react";
import { renderWithProviders } from "../../test-utils";
// Create mocks for all the hooks we need
const useIsAuthedMock = vi
@@ -37,21 +36,30 @@ describe("UserActions", () => {
const onClickAccountSettingsMock = vi.fn();
const onLogoutMock = vi.fn();
// Create a wrapper with MemoryRouter and renderWithProviders
const renderWithRouter = (ui: ReactElement) => {
return renderWithProviders(<MemoryRouter>{ui}</MemoryRouter>);
// Create a wrapper with QueryClientProvider
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(ui, {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
beforeEach(() => {
// Reset all mocks to default values before each test
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
});
afterEach(() => {
@@ -61,14 +69,36 @@ describe("UserActions", () => {
});
it("should render", () => {
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
});
it("should toggle the user menu when the user avatar is clicked", async () => {
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
await user.click(userAvatar);
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should call onLogout and close the menu when the logout option is clicked", async () => {
renderWithRouter(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -82,21 +112,19 @@ describe("UserActions", () => {
await user.click(logoutOption);
expect(onLogoutMock).toHaveBeenCalledOnce();
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is not authenticated and avatar is clicked", async () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -108,7 +136,7 @@ describe("UserActions", () => {
});
it("should show context menu even when user has no avatar_url", async () => {
renderWithRouter(
renderWithQueryClient(
<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />,
);
@@ -125,15 +153,10 @@ describe("UserActions", () => {
// Set isAuthed to false for this test
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(<UserActions onLogout={onLogoutMock} />);
renderWithQueryClient(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -144,24 +167,17 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
// Logout option should NOT be accessible when user is not authenticated
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should handle user prop changing from undefined to defined", async () => {
// Start with no authentication
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { unmount } = renderWithRouter(
const { rerender } = renderWithQueryClient(
<UserActions onLogout={onLogoutMock} />,
);
@@ -172,36 +188,37 @@ describe("UserActions", () => {
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
// Unmount the first component
unmount();
// Set authentication to true for the new render
// Set authentication to true for the rerender
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
// Ensure config and providers are set correctly
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Add user prop and create a new QueryClient to ensure fresh state
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Render a new component with user prop and authentication
renderWithRouter(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
rerender(
<QueryClientProvider client={queryClient}>
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>
</QueryClientProvider>,
);
// Component should render correctly
// Component should still render correctly
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
// Menu should now work with user defined and authenticated
userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
expect(
screen.getByTestId("account-settings-context-menu"),
).toBeInTheDocument();
@@ -210,15 +227,10 @@ describe("UserActions", () => {
it("should handle user prop changing from defined to undefined", async () => {
// Start with authentication and providers
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
const { rerender } = renderWithRouter(
const { rerender } = renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
@@ -235,19 +247,14 @@ describe("UserActions", () => {
// Set authentication to false for the rerender
useIsAuthedMock.mockReturnValue({ data: false, isLoading: false });
// Keep other mocks with default values
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
// Remove user prop - menu should disappear because user is no longer authenticated
rerender(
<MemoryRouter>
<QueryClientProvider client={new QueryClient()}>
<UserActions onLogout={onLogoutMock} />
</MemoryRouter>,
</QueryClientProvider>,
);
// Context menu should NOT be visible when user becomes unauthenticated
@@ -256,23 +263,16 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
// Logout option should not be accessible
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
expect(screen.queryByText("ACCOUNT_SETTINGS$LOGOUT")).not.toBeInTheDocument();
});
it("should work with loading state and user provided", async () => {
// Ensure authentication and providers are set correctly
useIsAuthedMock.mockReturnValue({ data: true, isLoading: false });
useConfigMock.mockReturnValue({
data: { APP_MODE: "saas" },
isLoading: false,
});
useUserProvidersMock.mockReturnValue({
providers: [{ id: "github", name: "GitHub" }],
});
useConfigMock.mockReturnValue({ data: { APP_MODE: "saas" }, isLoading: false });
useUserProvidersMock.mockReturnValue({ providers: [{ id: "github", name: "GitHub" }] });
renderWithRouter(
renderWithQueryClient(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}

View File

@@ -3,15 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import i18n from "../../src/i18n";
import { AccountSettingsContextMenu } from "../../src/components/features/context-menu/account-settings-context-menu";
import { renderWithProviders } from "../../test-utils";
import { MemoryRouter } from "react-router";
describe("Translations", () => {
it("should render translated text", () => {
i18n.changeLanguage("en");
renderWithProviders(
<MemoryRouter>
<AccountSettingsContextMenu onLogout={() => {}} onClose={() => {}} />
</MemoryRouter>,
<AccountSettingsContextMenu
onLogout={() => {}}
onClose={() => {}}
/>,
);
expect(
screen.getByTestId("account-settings-context-menu"),

View File

@@ -37,27 +37,34 @@ const selectRepository = async (repoName: string) => {
// First select the provider
const providerDropdown = await waitFor(() =>
screen.getByTestId("git-provider-dropdown"),
screen.getByText("Select Provider"),
);
await userEvent.click(providerDropdown);
await userEvent.click(screen.getByText("GitHub"));
await userEvent.click(screen.getByText("Github"));
// Then select the repository
const repoInput = within(repoConnector).getByTestId("git-repo-dropdown");
const dropdown = within(repoConnector).getByTestId("repo-dropdown");
const repoInput = within(dropdown).getByRole("combobox");
await userEvent.click(repoInput);
// Wait for the options to be loaded and displayed
await waitFor(() => {
const dropdownMenu = screen.getByTestId("git-repo-dropdown-menu");
expect(within(dropdownMenu).getByText(repoName)).toBeInTheDocument();
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");
await userEvent.click(within(dropdownMenu).getByText(repoName));
const options = screen.getAllByText(repoName);
const dropdownOption = options.find(
(el) => el.getAttribute("role") === "option",
);
await userEvent.click(dropdownOption!);
// Wait for the branch to be auto-selected
await waitFor(() => {
const branchInput = screen.getByTestId("git-branch-dropdown-input");
expect(branchInput).toHaveValue("main");
expect(screen.getByText("main")).toBeInTheDocument();
});
};
@@ -78,14 +85,12 @@ 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",
},
];
@@ -95,8 +100,8 @@ describe("HomeScreen", () => {
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "fake-token",
gitlab: "fake-token",
github: null,
gitlab: null,
},
});
});
@@ -118,144 +123,27 @@ describe("HomeScreen", () => {
it("should have responsive layout for mobile and desktop screens", async () => {
renderHomeScreen();
const homeScreenNewConversationSection = screen.getByTestId(
"home-screen-new-conversation-section",
);
expect(homeScreenNewConversationSection).toHaveClass(
"flex",
"flex-col",
"md:flex-row",
);
const homeScreenRecentConversationsSection = screen.getByTestId(
"home-screen-recent-conversations-section",
);
expect(homeScreenRecentConversationsSection).toHaveClass(
"flex",
"flex-col",
"md:flex-row",
);
const mainContainer = screen
.getByTestId("home-screen")
.querySelector("main");
expect(mainContainer).toHaveClass("flex", "flex-col", "lg:flex-row");
});
it("should filter the suggested tasks based on the selected repository", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
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,
});
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select a repository using the helper function
await selectRepository("octocat/hello-world");
// After selecting a repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
});
it("should filter tasks when different repositories are selected", async () => {
const retrieveUserGitRepositoriesSpy = vi.spyOn(
OpenHands,
"retrieveUserGitRepositories",
);
retrieveUserGitRepositoriesSpy.mockResolvedValue({
data: MOCK_RESPOSITORIES,
nextPage: null,
});
// Mock the repository branches API call
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,
});
renderHomeScreen();
const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
within(taskSuggestions).getByText("octocat/earth");
});
// Select the first repository
await selectRepository("octocat/hello-world");
// After selecting first repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/hello-world");
expect(
within(taskSuggestions).queryByText("octocat/earth"),
).not.toBeInTheDocument();
});
// Now select the second repository
await selectRepository("octocat/earth");
// After selecting second repository, only tasks related to that repository should be visible
await waitFor(() => {
within(taskSuggestions).getByText("octocat/earth");
expect(
within(taskSuggestions).queryByText("octocat/hello-world"),
).not.toBeInTheDocument();
});
});
// TODO: Fix this test
it.skip("should filter and reset the suggested tasks based on repository selection", async () => {});
describe("launch buttons", () => {
const setupLaunchButtons = async () => {
let headerLaunchButton = screen.getByTestId(
"launch-new-conversation-button",
);
let headerLaunchButton = screen.getByTestId("header-launch-button");
let repoLaunchButton = await screen.findByTestId("repo-launch-button");
let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button");
// Mock the repository branches API call
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,
});
vi.spyOn(OpenHands, "getRepositoryBranches").mockResolvedValue([
{ name: "main", commit_sha: "123", protected: false },
{ name: "develop", commit_sha: "456", protected: false },
]);
// Select a repository to enable the repo launch button
await selectRepository("octocat/hello-world");
@@ -269,7 +157,8 @@ describe("HomeScreen", () => {
});
});
headerLaunchButton = screen.getByTestId("launch-new-conversation-button");
// Get fresh references to the buttons
headerLaunchButton = screen.getByTestId("header-launch-button");
repoLaunchButton = screen.getByTestId("repo-launch-button");
tasksLaunchButtons = await screen.findAllByTestId("task-launch-button");

View File

@@ -79,35 +79,6 @@ describe("Content", () => {
expect(screen.getByTestId("set-indicator")).toBeInTheDocument();
});
});
it("should conditionally show security analyzer based on confirmation mode", async () => {
renderLlmSettingsScreen();
await screen.findByTestId("llm-settings-screen");
const confirmation = screen.getByTestId("enable-confirmation-mode-switch");
// Initially confirmation mode is false, so security analyzer should not be visible
expect(confirmation).not.toBeChecked();
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
// Enable confirmation mode
await userEvent.click(confirmation);
expect(confirmation).toBeChecked();
// Security analyzer should now be visible
screen.getByTestId("security-analyzer-input");
// Disable confirmation mode again
await userEvent.click(confirmation);
expect(confirmation).not.toBeChecked();
// Security analyzer should be hidden again
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
});
});
describe("Advanced form", () => {
@@ -136,6 +107,7 @@ describe("Content", () => {
within(advancedForm).getByTestId("llm-api-key-input");
within(advancedForm).getByTestId("llm-api-key-help-anchor-advanced");
within(advancedForm).getByTestId("agent-input");
within(advancedForm).getByTestId("enable-confirmation-mode-switch");
within(advancedForm).getByTestId("enable-memory-condenser-switch");
await userEvent.click(advancedSwitch);
@@ -158,6 +130,9 @@ describe("Content", () => {
const baseUrl = screen.getByTestId("base-url-input");
const apiKey = screen.getByTestId("llm-api-key-input");
const agent = screen.getByTestId("agent-input");
const confirmation = screen.getByTestId(
"enable-confirmation-mode-switch",
);
const condensor = screen.getByTestId("enable-memory-condenser-switch");
expect(model).toHaveValue("openhands/claude-sonnet-4-20250514");
@@ -165,7 +140,15 @@ describe("Content", () => {
expect(apiKey).toHaveValue("");
expect(apiKey).toHaveProperty("placeholder", "");
expect(agent).toHaveValue("CodeActAgent");
expect(confirmation).not.toBeChecked();
expect(condensor).toBeChecked();
// check that security analyzer is present
expect(
screen.queryByTestId("security-analyzer-input"),
).not.toBeInTheDocument();
await userEvent.click(confirmation);
screen.getByTestId("security-analyzer-input");
});
it("should render the advanced form if existings settings are advanced", async () => {
@@ -194,7 +177,7 @@ describe("Content", () => {
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: "none",
security_analyzer: "mock-invariant",
});
renderLlmSettingsScreen();
@@ -220,7 +203,7 @@ describe("Content", () => {
expect(agent).toHaveValue("CoActAgent");
expect(confirmation).toBeChecked();
expect(condensor).not.toBeChecked();
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
expect(securityAnalyzer).toHaveValue("mock-invariant");
});
});
});
@@ -310,7 +293,7 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = screen.getByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
const submitButton = screen.getByTestId("submit-button");
@@ -323,7 +306,7 @@ describe("Form submission", () => {
agent: "CoActAgent",
confirmation_mode: true,
enable_default_condenser: false,
security_analyzer: null,
security_analyzer: "mock-invariant",
}),
);
});
@@ -392,10 +375,8 @@ describe("Form submission", () => {
const baseUrl = await screen.findByTestId("base-url-input");
const apiKey = await screen.findByTestId("llm-api-key-input");
const agent = await screen.findByTestId("agent-input");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
// Confirmation mode switch is now in basic settings, always visible
const confirmation = await screen.findByTestId("enable-confirmation-mode-switch");
const condensor = await screen.findByTestId("enable-memory-condenser-switch");
// enter custom model
await userEvent.type(model, "-mini");
@@ -470,17 +451,14 @@ describe("Form submission", () => {
// select security analyzer
const securityAnalyzer = await screen.findByTestId("security-analyzer-input");
await userEvent.click(securityAnalyzer);
const securityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_NONE");
const securityAnalyzerOption = screen.getByText("mock-invariant");
await userEvent.click(securityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_NONE");
expect(securityAnalyzer).toHaveValue("mock-invariant");
expect(submitButton).not.toBeDisabled();
// revert back to original value
await userEvent.click(securityAnalyzer);
const originalSecurityAnalyzerOption = screen.getByText("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
await userEvent.click(originalSecurityAnalyzerOption);
expect(securityAnalyzer).toHaveValue("SETTINGS$SECURITY_ANALYZER_LLM_DEFAULT");
await userEvent.clear(securityAnalyzer);
expect(securityAnalyzer).toHaveValue("");
expect(submitButton).toBeDisabled();
});
@@ -574,7 +552,7 @@ describe("Form submission", () => {
expect.objectContaining({
llm_model: "openhands/claude-sonnet-4-20250514",
llm_base_url: "",
confirmation_mode: true, // Confirmation mode is now a basic setting, should be preserved
confirmation_mode: false,
}),
);
});

View File

@@ -107,7 +107,9 @@ describe("Content", () => {
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
);
const button = await screen.findByTestId("connect-git-button");
expect(button).toHaveAttribute("href", "/settings/integrations");
await userEvent.click(button);
screen.getByTestId("git-settings-screen");
});
it("should render an empty table when there are no existing secrets", async () => {

View File

@@ -1,73 +1,18 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { ChatInput } from "#/components/features/chat/chat-input";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
// Return a mock translation for the test
const translations: Record<string, string> = {
CHAT$PLACEHOLDER: "What do you want to build?",
};
return translations[key] || key;
},
}),
};
});
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: null,
}),
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
describe("Check for hardcoded English strings", () => {
test("InteractiveChatBox should not have hardcoded English strings", () => {
const { container } = renderWithProviders(
<MemoryRouter>
<InteractiveChatBox
onSubmit={() => {}}
onStop={() => {}}
isWaitingForUserInput={false}
hasSubstantiveAgentActions={false}
optimisticUserMessage={false}
/>
</MemoryRouter>,
const { container } = render(
<InteractiveChatBox onSubmit={() => {}} onStop={() => {}} />,
);
// Get all text content
@@ -77,7 +22,7 @@ describe("Check for hardcoded English strings", () => {
const hardcodedStrings = [
"What do you want to build?",
"Launch from Scratch",
"Read this",
"Read this"
];
// Check each string
@@ -85,4 +30,9 @@ describe("Check for hardcoded English strings", () => {
expect(text).not.toContain(str);
});
});
test("ChatInput should use translation key for placeholder", () => {
render(<ChatInput onSubmit={() => {}} />);
screen.getByPlaceholderText("SUGGESTIONS$WHAT_TO_BUILD");
});
});

View File

@@ -1,5 +1,8 @@
import { expect, test } from "vitest";
import { SuggestedTask, SuggestedTaskGroup } from "#/utils/types";
import {
SuggestedTask,
SuggestedTaskGroup,
} from "#/components/features/home/tasks/task.types";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
const rawTasks: SuggestedTask[] = [

View File

@@ -29,5 +29,23 @@ describe("hasAdvancedSettingsSet", () => {
}),
).toBe(true);
});
test("CONFIRMATION_MODE is true", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
CONFIRMATION_MODE: true,
}),
).toBe(true);
});
test("SECURITY_ANALYZER is set", () => {
expect(
hasAdvancedSettingsSet({
...DEFAULT_SETTINGS,
SECURITY_ANALYZER: "test",
}),
).toBe(true);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -11,53 +11,50 @@
"@heroui/use-infinite-scroll": "^2.2.10",
"@microlink/react-json-view": "^1.26.2",
"@monaco-editor/react": "^4.7.0-rc.0",
"@openhands/ui": "1.0.0-beta.9",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@react-types/shared": "^3.32.0",
"@react-router/node": "^7.8.0",
"@react-router/serve": "^7.8.0",
"@react-types/shared": "^3.31.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.2",
"@stripe/stripe-js": "^7.9.0",
"@stripe/react-stripe-js": "^3.9.0",
"@stripe/stripe-js": "^7.8.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query": "^5.85.3",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.2",
"@vitejs/plugin-react": "^5.0.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"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.4.2",
"i18next": "^25.3.6",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.30",
"jose": "^6.1.0",
"lucide-react": "^0.542.0",
"isbot": "^5.1.29",
"jose": "^6.0.12",
"lucide-react": "^0.539.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.261.0",
"posthog-js": "^1.260.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.6.0",
"react-i18next": "^15.7.2",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-redux": "^9.2.0",
"react-resizable-panels": "^3.0.5",
"react-router": "^7.8.2",
"react-syntax-highlighter": "^15.6.6",
"react-router": "^7.8.0",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.1",
"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",
"tailwind-scrollbar": "^4.0.2",
"vite": "^7.1.3",
"vite": "^7.1.1",
"web-vitals": "^5.1.0",
"ws": "^8.18.2"
},
@@ -91,17 +88,17 @@
"@babel/traverse": "^7.28.3",
"@babel/types": "^7.28.2",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.55.0",
"@react-router/dev": "^7.8.2",
"@playwright/test": "^1.54.2",
"@react-router/dev": "^7.8.0",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/node": "^24.2.0",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -120,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.2.0",
"eslint-plugin-unused-imports": "^4.1.4",
"husky": "^9.1.7",
"jsdom": "^26.1.0",
"lint-staged": "^16.1.4",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.5.0",
"stripe": "^18.4.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-svgr": "^4.2.0",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.0.2"
},

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

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

View File

@@ -21,13 +21,8 @@ import {
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { SuggestedTask } from "#/utils/types";
import {
GitUser,
GitRepository,
PaginatedBranchesResponse,
Branch,
} from "#/types/git";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { extractNextPageFromLink } from "#/utils/extract-next-page-from-link";
import { RepositoryMicroagent } from "#/types/microagent-management";
import { BatchFeedbackData } from "#/hooks/query/use-batch-feedback";
@@ -540,15 +535,6 @@ class OpenHands {
per_page = 30,
) {
const installationId = installations[installationIndex];
console.log('🔧 API: Fetching repos for installation', {
selected_provider,
installationIndex,
installationId,
installations: installations.map((id, idx) => `${idx}:${id}`),
page,
per_page,
});
const response = await openHands.get<GitRepository[]>(
"/api/user/repositories",
{
@@ -561,7 +547,6 @@ class OpenHands {
},
},
);
const link =
response.data.length > 0 && response.data[0].link_header
? response.data[0].link_header
@@ -575,54 +560,18 @@ class OpenHands {
} else {
nextInstallation = null;
}
const result = {
return {
data: response.data,
nextPage,
installationIndex: nextInstallation,
};
console.log('🔧 API: Installation repos response', {
installationIndex,
installationId,
dataCount: result.data.length,
repos: result.data.map(r => r.full_name),
nextPage: result.nextPage,
nextInstallationIndex: result.installationIndex,
});
return result;
}
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[]> {
static async getRepositoryBranches(repository: string): Promise<Branch[]> {
const { data } = await openHands.get<Branch[]>(
`/api/user/search/branches`,
{
params: {
repository,
query,
per_page: perPage,
selected_provider: selectedProvider,
},
},
`/api/user/repository/branches?repository=${encodeURIComponent(repository)}`,
);
return data;
}
@@ -777,27 +726,6 @@ 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;

View File

@@ -146,11 +146,6 @@ export interface GetMicroagentPromptResponse {
prompt: string;
}
export interface IOption<T> {
label: string;
value: T;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;

View File

@@ -1,4 +1,4 @@
import { SuggestedTask } from "#/utils/types";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { openHands } from "../open-hands-axios";
export class SuggestionsService {

View File

@@ -1,16 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="47" height="30" viewBox="0 0 47 30" fill="none">
<g clip-path="url(#clip0_10905_18559)">
<path d="M44.731 8.9991C43.271 8.13859 42.2956 9.4574 42.4152 11.248L42.4031 11.2616C42.4071 9.39165 42.1435 7.32642 41.2675 5.65567C40.9573 5.06395 40.3287 4.09128 39.0856 4.54957C38.5402 4.75068 38.0454 5.35594 38.3009 6.9184C38.3009 6.9184 38.5848 8.55821 38.532 10.6196V10.6486C38.1772 4.96339 36.8388 3.22883 34.9246 3.34099C34.3122 3.44541 33.4748 3.69873 33.7566 5.44683C33.7566 5.44683 34.0628 7.27034 34.1622 8.72258L34.1683 8.79606H34.1622C33.2618 5.66147 32.0492 5.61893 31.1712 5.74076C30.3743 5.85098 29.5044 6.64381 29.9444 8.20627C31.3253 13.1083 31.0556 19.012 30.9522 19.857C30.6703 19.2789 30.5831 18.8206 30.1918 18.1863C28.6182 15.6396 27.87 15.452 26.9514 15.4133C26.0389 15.3746 25.0534 15.9141 25.1183 16.941C25.1852 17.9678 25.7307 18.1379 26.5053 19.5689C27.1096 20.6827 27.2819 22.1427 28.4986 24.7958C29.5064 26.9925 32.1405 29.402 36.9382 29.1158C40.8255 28.992 46.631 27.6887 45.6212 19.13C45.3697 17.6429 45.5583 16.3976 45.6901 15.1213C45.8949 13.1412 46.195 9.85962 44.733 8.99717L44.731 8.9991Z" fill="#FFE165"/>
<path d="M20.458 15.4707C19.5395 15.5268 18.7973 15.7259 17.2724 18.2998C16.8932 18.9398 16.8161 19.4 16.5444 19.9821C16.4248 19.139 16.0415 13.2411 17.3272 8.31587C17.7368 6.74761 16.8526 5.97024 16.0537 5.87356C15.1736 5.7672 13.959 5.83101 13.1195 8.99654H13.1094L13.1215 8.90566C13.1925 7.45149 13.4642 5.62411 13.4642 5.62411C13.7096 3.87021 12.8701 3.63236 12.2557 3.5376C10.3455 3.46025 9.04367 5.20255 8.79222 10.8375H8.78817C8.70097 8.79737 8.95039 7.17303 8.95039 7.17303C9.17547 5.60477 8.66853 5.00918 8.119 4.81774C6.86786 4.38071 6.25749 5.36498 5.95941 5.96251C5.11585 7.64873 4.89077 9.71783 4.93133 11.5878L4.91916 11.5742C5.0023 9.78164 4.0026 8.48023 2.55882 9.36589C1.11504 10.2535 1.47802 13.5292 1.72135 15.5055C1.87952 16.7798 2.09041 18.0213 1.86735 19.5122C1.02379 28.0864 6.85366 29.2872 10.7429 29.3433C15.5447 29.5464 18.1322 27.0886 19.0974 24.8745C20.2613 22.202 20.4074 20.7382 20.9893 19.6147C21.7355 18.1702 22.279 17.9904 22.3256 16.9635C22.3723 15.9367 21.3766 15.4146 20.4641 15.4688L20.458 15.4707Z" fill="#FFE165"/>
<path d="M22.3819 15.4845C21.8952 15.0301 21.1632 14.7884 20.419 14.8309C19.2266 14.9025 18.3811 15.3182 17.0813 17.3487C17.0468 15.0262 17.1826 11.5397 17.9816 8.47281C18.2817 7.3203 17.9796 6.56808 17.6713 6.14072C17.3124 5.64182 16.7548 5.31308 16.1383 5.2396C15.5766 5.17192 14.8426 5.16805 14.1268 5.72884C14.1268 5.7211 14.1288 5.71143 14.1288 5.71143C14.36 4.06389 13.7638 3.12023 12.3586 2.90751L12.2815 2.89978C11.4156 2.86304 10.6735 3.13376 10.0753 3.70228C9.75488 4.00588 9.47707 4.39843 9.23577 4.88379C8.96607 4.50672 8.61932 4.31527 8.34557 4.21859C6.67265 3.63267 5.74799 4.88766 5.34649 5.68823C4.8801 6.62029 4.59012 7.66451 4.4279 8.73C4.39343 8.70873 4.36098 8.68746 4.32651 8.66812C3.95746 8.46508 3.18893 8.21756 2.19126 8.83055C0.500091 9.8709 0.715036 12.8605 1.05165 15.5832C1.0699 15.7282 1.08815 15.8713 1.1064 16.0163C1.25037 17.1321 1.38623 18.186 1.19968 19.4255L1.19562 19.4564C0.85698 22.8966 1.53629 25.5438 3.21529 27.3287C4.8294 29.0458 7.35804 29.9392 10.71 29.9876C10.9553 29.9972 11.1946 30.0011 11.4278 29.9992C17.1543 29.9489 19.2084 26.2845 19.7133 25.1242C20.3663 23.6236 20.7049 22.504 20.9746 21.6029C21.1835 20.9067 21.3497 20.3576 21.585 19.9012C21.8526 19.383 22.0878 19.0465 22.2947 18.7487C22.6475 18.2421 22.9517 17.805 22.9882 16.9929C23.0145 16.405 22.8036 15.8829 22.3758 15.4845H22.3819ZM11.0263 4.61114C11.3487 4.30561 11.7198 4.17024 12.1902 4.17991C12.5978 4.24373 12.9669 4.33848 12.7986 5.5374C12.7864 5.61281 12.5228 7.41312 12.4518 8.87889C12.4518 8.88856 12.4518 8.89823 12.4518 8.9079C12.0807 10.3389 11.7705 12.4002 11.6042 15.413C10.8844 15.4555 10.1665 15.529 9.46896 15.6257C9.24388 9.51316 9.76502 5.80619 11.0243 4.61114H11.0263ZM6.56315 6.24128C7.06807 5.23573 7.49188 5.28601 7.88527 5.42331C8.43074 5.61475 8.34557 6.65316 8.28271 7.08439C8.27257 7.154 8.02924 8.77254 8.11441 10.832C8.05155 12.2765 8.05966 13.9414 8.13468 15.8462C7.46754 15.9718 6.83488 16.1169 6.25696 16.2735C5.98321 15.3956 4.77262 9.81869 6.56315 6.24321V6.24128ZM21.1794 18.039C20.9604 18.3523 20.6887 18.7429 20.3825 19.3346C20.0925 19.8935 19.9141 20.4929 19.6849 21.249C19.4233 22.1173 19.0969 23.1982 18.4743 24.6311C18.0323 25.6444 16.1748 28.9356 10.7505 28.7036C7.7271 28.661 5.58982 27.9301 4.21701 26.4701C2.80162 24.9657 2.23587 22.649 2.53395 19.5879C2.74079 18.1879 2.5887 17.0025 2.44068 15.8578C2.42243 15.7147 2.40418 15.5735 2.38593 15.4304C2.2237 14.1097 1.78976 10.5999 2.91923 9.90571C3.2234 9.71814 3.47282 9.6756 3.65735 9.77615C3.97165 9.94825 4.28798 10.5748 4.24337 11.5455C4.24135 11.5977 4.24743 11.648 4.25757 11.6983C4.31435 13.9608 4.73815 15.9293 4.97946 16.668C4.58404 16.8092 4.23526 16.9561 3.94326 17.1031C3.61476 17.2694 3.49107 17.6561 3.66546 17.9694C3.78712 18.1879 4.02235 18.3117 4.26568 18.3097C4.3691 18.3097 4.47454 18.2846 4.5739 18.2343C6.21438 17.4047 10.1057 16.5616 13.5347 16.6525C13.9078 16.6583 14.214 16.3837 14.2241 16.0299C14.2342 15.676 13.9422 15.3821 13.5712 15.3724C13.3664 15.3666 13.1595 15.3666 12.9527 15.3666C13.2954 9.29078 14.2383 7.3087 14.9724 6.72278C15.2765 6.48106 15.5665 6.46172 15.968 6.51007C16.0795 6.5236 16.3594 6.58548 16.5601 6.86394C16.7771 7.16754 16.8176 7.61616 16.6757 8.16148C15.4347 12.9204 15.7145 18.5166 15.8565 19.8741C15.8321 19.9205 15.8098 19.9669 15.7835 20.0153C15.4935 20.5355 14.9541 21.0769 14.3113 21.0402C13.9443 21.0228 13.6219 21.2896 13.5996 21.6416C13.5772 21.9954 13.8591 22.299 14.2302 22.3203C15.3171 22.3822 16.3411 21.746 16.9697 20.6186C17.0366 20.4987 17.0934 20.3846 17.1441 20.2744C17.1482 20.2667 17.1522 20.257 17.1563 20.2493C17.2739 19.9979 17.3591 19.7678 17.4341 19.5609C17.5517 19.2399 17.6531 18.9614 17.8559 18.6172C19.2956 16.1846 19.8796 16.1497 20.4981 16.113C20.861 16.0917 21.222 16.202 21.4349 16.4031C21.587 16.5442 21.6539 16.7202 21.6438 16.9406C21.6235 17.3951 21.4917 17.5846 21.1733 18.0409L21.1794 18.039Z" fill="#0D0F11"/>
<path d="M46.2793 19.0284C46.0704 17.7928 46.186 16.7369 46.3077 15.6193C46.3239 15.4742 46.3401 15.3311 46.3543 15.1861C46.6382 12.4595 46.7964 9.46417 45.0829 8.45476C44.073 7.85916 43.3086 8.12022 42.9436 8.32906C42.9091 8.3484 42.8766 8.3716 42.8422 8.39288C42.6576 7.33125 42.3494 6.29284 41.8648 5.36851C41.4491 4.57568 40.5021 3.33615 38.8393 3.95108C38.5676 4.05164 38.2269 4.24888 37.9633 4.63176C37.7119 4.15026 37.426 3.76351 37.0995 3.46571C36.4912 2.9088 35.7429 2.64968 34.8791 2.70189L34.802 2.70962C33.4008 2.94747 32.8229 3.9008 33.0865 5.54835C33.0865 5.54835 33.0865 5.55608 33.0885 5.56188C32.3626 5.0127 31.6285 5.03011 31.0689 5.10746C30.4545 5.19254 29.9029 5.53094 29.5541 6.03565C29.256 6.46881 28.9661 7.2249 29.2885 8.3716C30.1483 11.425 30.351 14.9096 30.3612 17.232C29.0228 15.2248 28.1692 14.8245 26.9768 14.7742C26.2346 14.7433 25.5026 15.0005 25.0261 15.4626C24.6063 15.8687 24.4056 16.3947 24.4441 16.9806C24.4968 17.7908 24.8091 18.224 25.1721 18.7229C25.385 19.0168 25.6263 19.3494 25.9041 19.8619C26.1495 20.3144 26.3259 20.8597 26.549 21.552C26.8369 22.4473 27.1958 23.5611 27.8792 25.0501C28.4064 26.2007 30.5315 29.8303 36.2417 29.7781C36.4729 29.7761 36.7122 29.7684 36.9555 29.7529C40.3257 29.6466 42.8361 28.7068 44.4178 26.9625C46.0603 25.1487 46.6889 22.4898 46.2853 19.0555L46.2813 19.0246L46.2793 19.0284ZM38.961 6.82075C38.89 6.38372 38.7826 5.34724 39.326 5.14806C39.7153 5.00303 40.1412 4.94696 40.6643 5.94283C42.5238 9.48737 41.4227 15.0855 41.1652 15.9673C40.5832 15.8204 39.9485 15.6869 39.2794 15.5728C39.3159 13.6681 39.2915 12.0012 39.2023 10.5587C39.2469 8.49923 38.9732 6.88456 38.961 6.82075ZM34.9967 3.98009C35.4692 3.96075 35.8423 4.09031 36.1687 4.39197C37.4503 5.56575 38.0444 9.26112 37.937 15.3775C37.2374 15.2924 36.5196 15.2325 35.7977 15.2016C35.5746 12.1907 35.2238 10.1371 34.8243 8.71194C34.8243 8.70227 34.8243 8.69261 34.8243 8.68294C34.725 7.21716 34.4249 5.42266 34.4127 5.35304C34.22 4.15219 34.5871 4.05164 34.9947 3.98009H34.9967ZM44.9511 19.2179C45.308 22.2732 44.7868 24.5995 43.4018 26.1291C42.0574 27.6123 39.9343 28.3819 36.8927 28.4786C31.4988 28.8035 29.5724 25.5471 29.1121 24.5415C28.4591 23.1183 28.1124 22.0451 27.8346 21.1807C27.5912 20.4265 27.4006 19.8329 27.1005 19.2779C26.7842 18.692 26.5043 18.3071 26.2793 17.9977C25.9528 17.5472 25.8169 17.3596 25.7865 16.9052C25.7723 16.6847 25.8372 16.5068 25.9852 16.3637C26.1961 16.1588 26.553 16.0408 26.918 16.0582C27.5365 16.0853 28.1205 16.1085 29.6089 18.516C29.8198 18.8563 29.9252 19.1328 30.0489 19.4519C30.13 19.6588 30.2192 19.8889 30.3429 20.1384C30.347 20.1461 30.349 20.1539 30.3531 20.1597C30.4078 20.2699 30.4666 20.382 30.5356 20.5019C31.1865 21.6177 32.2227 22.2365 33.3075 22.1553C33.6766 22.1282 33.9544 21.8188 33.926 21.4669C33.8976 21.1149 33.5752 20.8539 33.2041 20.8771C32.5613 20.9235 32.0118 20.3917 31.7117 19.8773C31.6833 19.829 31.661 19.7845 31.6367 19.7381C31.7522 18.3806 31.9246 12.7786 30.5903 8.04287C30.4362 7.49949 30.4687 7.05086 30.6795 6.7434C30.8762 6.46107 31.154 6.39339 31.2656 6.37792C31.665 6.32184 31.957 6.33731 32.2653 6.57323C33.0115 7.14755 33.9929 9.11223 34.4512 15.1803C34.2444 15.1822 34.0376 15.188 33.8348 15.1977C33.4637 15.2132 33.1778 15.5129 33.194 15.8668C33.2102 16.2206 33.5184 16.4875 33.8956 16.4778C37.3205 16.327 41.2301 17.1005 42.8848 17.903C42.9841 17.9513 43.0896 17.9726 43.195 17.9726C43.4383 17.9707 43.6715 17.843 43.7891 17.6207C43.9575 17.3055 43.8257 16.9187 43.4931 16.7582C43.1991 16.6151 42.8462 16.4759 42.4488 16.3405C42.6759 15.598 43.0632 13.6217 43.0754 11.3592C43.0855 11.309 43.0896 11.2587 43.0855 11.2065C43.0206 10.2377 43.3268 9.60533 43.6371 9.42742C43.8196 9.323 44.069 9.36168 44.3772 9.54345C45.5209 10.2183 45.1559 13.7339 45.018 15.0585C45.0038 15.2016 44.9876 15.3427 44.9713 15.4858C44.8456 16.6345 44.7158 17.8198 44.9511 19.2179Z" fill="#0D0F11"/>
<path d="M26.1508 6.85319C26.0434 6.85319 25.9339 6.83386 25.8304 6.78745C25.4512 6.62114 25.285 6.19379 25.4594 5.83218C26.0231 4.6584 26.8484 3.57551 27.844 2.70146C28.1502 2.43267 28.6288 2.45007 28.9106 2.744C29.1925 3.036 29.1742 3.49236 28.866 3.76115C28.0164 4.50757 27.3127 5.4319 26.8301 6.43357C26.7044 6.69463 26.4347 6.85126 26.1508 6.85319Z" fill="#F9F7F2"/>
<path d="M23.608 6.43744C23.2166 6.44131 22.8821 6.15511 22.8496 5.7761C22.7056 4.08021 22.6996 2.36112 22.8354 0.665235C22.8679 0.268818 23.2308 -0.0270433 23.6445 0.0019628C24.0602 0.0329026 24.3704 0.377108 24.34 0.773524C24.2103 2.394 24.2163 4.03767 24.3542 5.65814C24.3887 6.05456 24.0784 6.40263 23.6628 6.43357C23.6445 6.43357 23.6263 6.4355 23.608 6.4355V6.43744Z" fill="#F9F7F2"/>
<path d="M21.0084 6.88414C20.6697 6.888 20.3575 6.66949 20.2703 6.34269C19.9499 5.14377 19.3436 4.0048 18.5183 3.05147C18.2526 2.74401 18.2993 2.29151 18.6197 2.03819C18.9421 1.78487 19.4166 1.82935 19.6822 2.13488C20.6474 3.25258 21.3572 4.58492 21.7303 5.98688C21.8337 6.3717 21.5883 6.76425 21.1848 6.86287C21.124 6.87834 21.0652 6.88414 21.0043 6.88607L21.0084 6.88414Z" fill="#F9F7F2"/>
<svg width="39" height="26" viewBox="0 0 39 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_12391_446)">
<g clip-path="url(#clip1_12391_446)">
<path
d="M37.4642 7.79821C36.2186 7.05244 35.3865 8.1954 35.4886 9.74729L35.4782 9.75902C35.4816 8.13842 35.2567 6.34856 34.5094 4.90057C34.2447 4.38775 33.7084 3.54477 32.648 3.94196C32.1826 4.11625 31.7605 4.64081 31.9785 5.99494C31.9785 5.99494 32.2207 7.41611 32.1757 9.20262V9.22776C31.873 4.3006 30.7312 2.79731 29.0981 2.89451C28.5757 2.98501 27.8612 3.20456 28.1017 4.71958C28.1017 4.71958 28.3629 6.29995 28.4477 7.55856L28.4529 7.62224H28.4477C27.6796 4.9056 26.6451 4.86873 25.896 4.97431C25.2161 5.06984 24.474 5.75696 24.8494 7.11109C26.0275 11.3595 25.7974 16.4761 25.7092 17.2084C25.4687 16.7073 25.3943 16.3101 25.0604 15.7604C23.718 13.5533 23.0796 13.3907 22.296 13.3572C21.5175 13.3237 20.6767 13.7913 20.7321 14.6812C20.7892 15.5711 21.2545 15.7185 21.9154 16.9587C22.4309 17.924 22.5779 19.1893 23.6159 21.4887C24.4757 23.3925 26.7229 25.4807 30.816 25.2327C34.1323 25.1254 39.0851 23.9958 38.2236 16.5783C38.0091 15.2895 38.17 14.2102 38.2824 13.1041C38.4572 11.388 38.7132 8.54399 37.4659 7.79654L37.4642 7.79821Z"
fill="#FFE165" />
<path
d="M16.7567 13.4091C15.973 13.4577 15.3399 13.6303 14.039 15.8609C13.7155 16.4157 13.6497 16.8145 13.4179 17.319C13.3158 16.5883 12.9889 11.4768 14.0857 7.20822C14.4351 5.84906 13.6809 5.17535 12.9993 5.09156C12.2485 4.99938 11.2122 5.05469 10.496 7.79814H10.4874L10.4977 7.71938C10.5583 6.4591 10.7901 4.87536 10.7901 4.87536C10.9994 3.35532 10.2832 3.14918 9.75905 3.06706C8.12944 3.00003 7.01881 4.51002 6.8043 9.3936H6.80084C6.72645 7.62552 6.93923 6.21776 6.93923 6.21776C7.13126 4.8586 6.69877 4.34243 6.22995 4.17651C5.16258 3.79776 4.64186 4.65079 4.38756 5.16865C3.6679 6.63004 3.47587 8.42326 3.51047 10.0439L3.50009 10.0321C3.57102 8.47856 2.71816 7.35068 1.48643 8.11824C0.254707 8.88748 0.564368 11.7265 0.771962 13.4392C0.906898 14.5437 1.08681 15.6196 0.896519 16.9117C0.176859 24.3427 5.15047 25.3834 8.46851 25.432C12.565 25.608 14.7725 23.4779 15.5959 21.559C16.5889 19.2429 16.7135 17.9742 17.2099 17.0005C17.8466 15.7486 18.3102 15.5928 18.35 14.7029C18.3898 13.813 17.5404 13.3605 16.7619 13.4074L16.7567 13.4091Z"
fill="#FFE165" />
<path
d="M18.3964 13.4209C17.9812 13.027 17.3567 12.8176 16.7218 12.8544C15.7046 12.9164 14.9832 13.2768 13.8743 15.0365C13.8449 13.0237 13.9608 10.002 14.6424 7.34405C14.8984 6.34521 14.6406 5.69328 14.3777 5.3229C14.0715 4.89052 13.5958 4.60562 13.0699 4.54193C12.5907 4.48328 11.9644 4.47992 11.3537 4.96594C11.3537 4.95923 11.3555 4.95085 11.3555 4.95085C11.5527 3.52298 11.0441 2.70514 9.84523 2.52079L9.77949 2.51409C9.0408 2.48224 8.40764 2.71687 7.8973 3.20959C7.62397 3.4727 7.38697 3.81291 7.1811 4.23357C6.95102 3.90676 6.6552 3.74085 6.42165 3.65705C4.99445 3.14925 4.20559 4.23692 3.86306 4.93074C3.46517 5.73853 3.21779 6.64352 3.07939 7.56694C3.04998 7.54851 3.0223 7.53007 2.99289 7.51331C2.67804 7.33734 2.02239 7.12283 1.17126 7.65409C-0.271523 8.55573 -0.0881482 11.1467 0.199024 13.5064C0.214593 13.632 0.230163 13.7561 0.245732 13.8818C0.368559 14.8488 0.484465 15.7621 0.32531 16.8364L0.32185 16.8632C0.0329484 19.8446 0.612482 22.1389 2.04488 23.6858C3.42192 25.174 5.57917 25.9483 8.43878 25.9902C8.6481 25.9986 8.85223 26.0019 9.05118 26.0002C13.9366 25.9567 15.689 22.7808 16.1198 21.7753C16.6768 20.4748 16.9657 19.5044 17.1958 18.7234C17.374 18.1201 17.5158 17.6442 17.7165 17.2486C17.9449 16.7995 18.1455 16.5079 18.322 16.2498C18.623 15.8107 18.8825 15.432 18.9136 14.7281C18.9361 14.2186 18.7562 13.7661 18.3912 13.4209H18.3964ZM8.70865 3.99726C8.98371 3.73247 9.30029 3.61516 9.70164 3.62354C10.0494 3.67884 10.3642 3.76096 10.2206 4.80002C10.2102 4.86538 9.98535 6.42565 9.9248 7.69599C9.9248 7.70437 9.9248 7.71275 9.9248 7.72113C9.60822 8.9613 9.34354 10.7478 9.20168 13.3589C8.58755 13.3957 7.97515 13.4594 7.38005 13.5432C7.18802 8.24568 7.63262 5.03297 8.70692 3.99726H8.70865ZM4.90103 5.41005C5.33179 4.53858 5.69335 4.58215 6.02896 4.70114C6.49431 4.86706 6.42166 5.76702 6.36803 6.14075C6.35938 6.20108 6.15178 7.60381 6.22444 9.38865C6.17081 10.6406 6.17773 12.0835 6.24174 13.7343C5.67259 13.8432 5.13284 13.9689 4.63981 14.1047C4.40626 13.3438 3.37348 8.51048 4.90103 5.41173V5.41005ZM17.3705 15.6348C17.1837 15.9062 16.9519 16.2448 16.6906 16.7576C16.4433 17.2419 16.291 17.7615 16.0955 18.4168C15.8724 19.1692 15.5939 20.1061 15.0628 21.3479C14.6856 22.2261 13.101 25.0785 8.47338 24.8774C5.89402 24.8405 4.07065 24.207 2.89948 22.9417C1.69197 21.6378 1.20931 19.6301 1.46362 16.9772C1.64007 15.7638 1.51033 14.7365 1.38404 13.7443C1.36847 13.6203 1.3529 13.498 1.33733 13.374C1.19893 12.2293 0.828725 9.18754 1.79231 8.58589C2.0518 8.42333 2.26458 8.38646 2.42201 8.47361C2.69015 8.62276 2.96002 9.16576 2.92197 10.0071C2.92024 10.0523 2.92543 10.0959 2.93407 10.1395C2.98251 12.1003 3.34407 13.8063 3.54994 14.4465C3.2126 14.5689 2.91505 14.6962 2.66593 14.8236C2.38568 14.9677 2.28015 15.3029 2.42893 15.5744C2.53273 15.7638 2.7334 15.8711 2.94099 15.8694C3.02922 15.8694 3.11918 15.8476 3.20395 15.804C4.60348 15.0851 7.92325 14.3544 10.8486 14.4331C11.1669 14.4382 11.4281 14.2002 11.4368 13.8935C11.4454 13.5868 11.1963 13.3321 10.8797 13.3237C10.705 13.3187 10.5286 13.3187 10.3521 13.3187C10.6445 8.05295 11.4489 6.33515 12.0751 5.82735C12.3346 5.61786 12.582 5.6011 12.9245 5.643C13.0197 5.65473 13.2584 5.70836 13.4297 5.94969C13.6148 6.21281 13.6494 6.60162 13.5283 7.07423C12.4696 11.1986 12.7083 16.0487 12.8294 17.2252C12.8086 17.2654 12.7896 17.3056 12.7671 17.3475C12.5197 17.7983 12.0596 18.2676 11.5112 18.2358C11.198 18.2207 10.923 18.4519 10.904 18.757C10.8849 19.0637 11.1254 19.3268 11.442 19.3452C12.3692 19.3988 13.2428 18.8475 13.7791 17.8704C13.8362 17.7665 13.8847 17.6676 13.9279 17.5721C13.9314 17.5654 13.9348 17.557 13.9383 17.5503C14.0386 17.3324 14.1113 17.133 14.1753 16.9537C14.2756 16.6755 14.3621 16.4342 14.5351 16.1358C15.7634 14.0276 16.2616 13.9974 16.7892 13.9655C17.0989 13.9471 17.4068 14.0426 17.5885 14.2169C17.7182 14.3393 17.7753 14.4918 17.7667 14.6828C17.7494 15.0767 17.6369 15.2409 17.3653 15.6364L17.3705 15.6348Z"
fill="black" />
<path
d="M38.7854 16.4908C38.6072 15.4199 38.7058 14.5049 38.8096 13.5362C38.8235 13.4105 38.8373 13.2865 38.8494 13.1608C39.0916 10.7978 39.2265 8.20179 37.7647 7.32697C36.9032 6.81079 36.251 7.03704 35.9396 7.21803C35.9102 7.23479 35.8825 7.2549 35.8531 7.27334C35.6957 6.35327 35.4328 5.4533 35.0193 4.65222C34.6647 3.9651 33.8568 2.89084 32.4382 3.42378C32.2064 3.51093 31.9158 3.68187 31.6909 4.0137C31.4764 3.5964 31.2324 3.26122 30.9539 3.00313C30.4349 2.52047 29.7966 2.2959 29.0596 2.34115L28.9939 2.34785C27.7985 2.55399 27.3055 3.38021 27.5303 4.80808C27.5303 4.80808 27.5303 4.81478 27.5321 4.81981C26.9128 4.34385 26.2865 4.35894 25.809 4.42597C25.2849 4.49971 24.8143 4.793 24.5168 5.23041C24.2625 5.60581 24.0151 6.26109 24.2902 7.2549C25.0236 9.90116 25.1966 12.9211 25.2053 14.9339C24.0635 13.1943 23.3352 12.8474 22.318 12.8038C21.6848 12.777 21.0603 12.9999 20.6538 13.4004C20.2957 13.7524 20.1244 14.2082 20.1573 14.716C20.2023 15.4182 20.4687 15.7936 20.7784 16.226C20.96 16.4808 21.1659 16.769 21.4029 17.2131C21.6122 17.6053 21.7627 18.0779 21.953 18.6779C22.1986 19.4538 22.5048 20.4191 23.0878 21.7096C23.5376 22.7068 25.3506 25.8524 30.2221 25.8072C30.4194 25.8055 30.6235 25.7988 30.8311 25.7854C33.7063 25.6932 35.8479 24.8787 37.1973 23.3671C38.5986 21.7951 39.1349 19.4907 38.7906 16.5143L38.7871 16.4875L38.7854 16.4908ZM32.542 5.91083C32.4815 5.53207 32.3898 4.63379 32.8534 4.46117C33.1856 4.33548 33.5488 4.28687 33.9952 5.14997C35.5815 8.2219 34.6422 13.0736 34.4225 13.8379C33.926 13.7105 33.3845 13.5949 32.8136 13.496C32.8448 11.8452 32.824 10.4006 32.7479 9.15035C32.7859 7.36551 32.5524 5.96613 32.542 5.91083ZM29.16 3.44892C29.563 3.43216 29.8813 3.54445 30.1599 3.80589C31.2532 4.82316 31.7601 8.02582 31.6684 13.3267C31.0716 13.253 30.4592 13.201 29.8433 13.1742C29.653 10.5648 29.3537 8.78501 29.0129 7.54986C29.0129 7.54148 29.0129 7.5331 29.0129 7.52472C28.9281 6.25439 28.6721 4.69915 28.6617 4.63881C28.4974 3.59808 28.8105 3.51093 29.1582 3.44892H29.16ZM37.6523 16.6551C37.9568 19.303 37.5122 21.3191 36.3306 22.6447C35.1836 23.9302 33.3724 24.5972 30.7775 24.681C26.1758 24.9625 24.5323 22.1403 24.1396 21.2688C23.5826 20.0354 23.2868 19.1052 23.0498 18.3561C22.8422 17.7025 22.6796 17.188 22.4235 16.707C22.1537 16.1992 21.9149 15.8657 21.7229 15.5976C21.4444 15.2071 21.3285 15.0445 21.3025 14.6507C21.2904 14.4596 21.3458 14.3054 21.4721 14.1814C21.652 14.0038 21.9564 13.9015 22.2678 13.9166C22.7955 13.9401 23.2937 13.9602 24.5635 16.0467C24.7434 16.3417 24.8334 16.5813 24.9389 16.8578C25.0081 17.0372 25.0842 17.2366 25.1897 17.4528C25.1932 17.4595 25.1949 17.4662 25.1984 17.4712C25.2451 17.5668 25.2953 17.664 25.3541 17.7679C25.9094 18.7349 26.7934 19.2711 27.7189 19.2008C28.0338 19.1773 28.2708 18.9092 28.2465 18.6041C28.2223 18.2991 27.9473 18.0729 27.6307 18.093C27.0823 18.1332 26.6135 17.6723 26.3574 17.2265C26.3332 17.1846 26.3142 17.1461 26.2934 17.1059C26.392 15.9294 26.5391 11.0743 25.4008 6.97C25.2693 6.49907 25.297 6.11026 25.4769 5.84379C25.6447 5.59911 25.8817 5.54045 25.9769 5.52704C26.3177 5.47844 26.5668 5.49185 26.8297 5.69631C27.4663 6.19406 28.3036 7.89678 28.6946 13.1558C28.5181 13.1574 28.3417 13.1625 28.1687 13.1708C27.8521 13.1843 27.6082 13.444 27.622 13.7507C27.6359 14.0574 27.8988 14.2887 28.2206 14.2803C31.1425 14.1496 34.4778 14.8199 35.8895 15.5154C35.9742 15.5573 36.0642 15.5758 36.1541 15.5758C36.3617 15.5741 36.5607 15.4635 36.661 15.2708C36.8046 14.9976 36.6922 14.6624 36.4085 14.5233C36.1576 14.3993 35.8566 14.2786 35.5175 14.1613C35.7113 13.5178 36.0417 11.805 36.0521 9.84418C36.0607 9.8006 36.0642 9.75703 36.0607 9.71178C36.0054 8.87215 36.2666 8.32413 36.5313 8.16995C36.687 8.07945 36.8998 8.11297 37.1627 8.2705C38.1384 8.85539 37.827 11.9022 37.7094 13.0502C37.6973 13.1742 37.6834 13.2965 37.6696 13.4206C37.5623 14.416 37.4516 15.4434 37.6523 16.6551Z"
fill="black" />
<path
d="M21.6129 5.93941C21.5212 5.93941 21.4278 5.92265 21.3395 5.88242C21.016 5.7383 20.8742 5.36792 21.023 5.05453C21.5039 4.03725 22.208 3.09875 23.0574 2.34124C23.3186 2.10829 23.7269 2.12337 23.9673 2.37811C24.2078 2.63117 24.1922 3.02668 23.9293 3.25963C23.2044 3.90653 22.6041 4.70762 22.1924 5.57573C22.0851 5.80198 21.8551 5.93773 21.6129 5.93941Z"
fill="black" />
<path
d="M19.4429 5.57912C19.109 5.58247 18.8236 5.33443 18.7959 5.00596C18.6731 3.53619 18.6679 2.04631 18.7838 0.576537C18.8115 0.232976 19.1211 -0.0234375 19.474 0.0017011C19.8287 0.0285156 20.0934 0.326827 20.0674 0.670387C19.9567 2.0748 19.9619 3.49932 20.0795 4.90373C20.1089 5.24729 19.8442 5.54895 19.4896 5.57576C19.474 5.57576 19.4585 5.57744 19.4429 5.57744V5.57912Z"
fill="black" />
<path
d="M17.2247 5.96646C16.9358 5.96981 16.6694 5.78044 16.595 5.49721C16.3217 4.45815 15.8044 3.47104 15.1003 2.64482C14.8737 2.37835 14.9135 1.98618 15.1868 1.76664C15.4619 1.5471 15.8667 1.58564 16.0933 1.85044C16.9168 2.81911 17.5223 3.97381 17.8406 5.18884C17.9288 5.52235 17.7195 5.86255 17.3752 5.94803C17.3233 5.96143 17.2732 5.96646 17.2213 5.96814L17.2247 5.96646Z"
fill="black" />
</g>
</g>
<defs>
<clipPath id="clip0_10905_18559">
<rect width="45.7143" height="30" fill="white" transform="translate(0.818359)"/>
<clipPath id="clip0_12391_446">
<rect width="39" height="26" fill="white" />
</clipPath>
<clipPath id="clip1_12391_446">
<rect width="39" height="26" fill="white" />
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

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

View File

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

View File

@@ -0,0 +1,208 @@
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>
)}
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ export function AnalyticsConsentFormModal({
{t(I18nKey.ANALYTICS$DESCRIPTION)}
</BaseModalDescription>
<label className="flex gap-2 items-center self-start text-sm">
<label className="flex gap-2 items-center self-start">
<input name="analytics" type="checkbox" defaultChecked />
{t(I18nKey.ANALYTICS$SEND_ANONYMOUS_DATA)}
</label>

View File

@@ -5,14 +5,6 @@ import { SuggestionItem } from "#/components/features/suggestions/suggestion-ite
import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import {
getGitPushPrompt,
getCreatePRPrompt,
getPushToPRPrompt,
getPR,
getPRShort,
} from "#/utils/utils";
import { Provider } from "#/types/settings";
interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void;
@@ -29,20 +21,31 @@ export function ActionSuggestions({
const providersAreSet = providers.length > 0;
// Use the git_provider from the conversation, not the user's authenticated providers
const currentGitProvider = conversation?.git_provider as Provider;
const currentGitProvider = conversation?.git_provider;
const isGitLab = currentGitProvider === "gitlab";
const isBitbucket = currentGitProvider === "bitbucket";
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
const getProviderName = () => {
if (isGitLab) return "GitLab";
if (isBitbucket) return "Bitbucket";
return "GitHub";
};
const terms = {
pr: getPR(currentGitProvider === "gitlab"),
prShort: getPRShort(currentGitProvider === "gitlab"),
pushToBranch: getGitPushPrompt(currentGitProvider),
createPR: getCreatePRPrompt(currentGitProvider),
pushToPR: getPushToPRPrompt(currentGitProvider),
pr,
prShort,
pushToBranch: `Please push the changes to a remote branch on ${getProviderName()}, but do NOT create a ${pr}. Check your current branch name first - if it's main, master, deploy, or another common default branch name, create a new branch with a descriptive name related to your changes. Otherwise, use the exact SAME branch name as the one you are currently on.`,
createPR: `Please push the changes to ${getProviderName()} and open a ${pr}. If you're on a default branch (e.g., main, master, deploy), create a new branch with a descriptive name otherwise use the current branch. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`,
pushToPR: `Please push the latest changes to the existing ${pr}.`,
};
return (
<div className="flex flex-col gap-2 mb-2">
{providersAreSet && conversation?.selected_repository && (
<div className="flex flex-row gap-2 w-full">
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
<SuggestionItem

View File

@@ -1,25 +0,0 @@
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface ChatActionTooltipProps {
children: React.ReactNode;
tooltip: string | React.ReactNode;
ariaLabel: string;
}
export function ChatActionTooltip({
children,
tooltip,
ariaLabel,
}: ChatActionTooltipProps) {
return (
<TooltipButton
tooltip={tooltip}
ariaLabel={ariaLabel}
disabled={false}
placement="bottom"
tooltipClassName="bg-white text-black text-xs font-medium leading-5"
>
{children}
</TooltipButton>
);
}

View File

@@ -1,30 +0,0 @@
import PaperclipIcon from "#/icons/paper-clip.svg?react";
import { cn } from "#/utils/utils";
export interface ChatAddFileButtonProps {
handleFileIconClick: () => void;
disabled?: boolean;
}
export function ChatAddFileButton({
handleFileIconClick,
disabled = false,
}: ChatAddFileButtonProps) {
return (
<button
type="button"
className={cn(
"h-[25px] relative shrink-0 w-[13px] cursor-pointer transition-all duration-200 hover:scale-110 active:scale-95",
disabled && "cursor-not-allowed",
)}
data-name="Shape"
data-testid="paperclip-icon"
onClick={handleFileIconClick}
>
<PaperclipIcon
className="block max-w-none w-[13px] h-[25px]"
color={disabled ? "#959CB2" : "white"}
/>
</button>
);
}

View File

@@ -0,0 +1,149 @@
import React from "react";
import TextareaAutosize from "react-textarea-autosize";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import { SubmitButton } from "#/components/shared/buttons/submit-button";
import { StopButton } from "#/components/shared/buttons/stop-button";
interface ChatInputProps {
name?: string;
button?: "submit" | "stop";
disabled?: boolean;
showButton?: boolean;
value?: string;
maxRows?: number;
onSubmit: (message: string) => void;
onStop?: () => void;
onChange?: (message: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onFilesPaste?: (files: File[]) => void;
className?: React.HTMLAttributes<HTMLDivElement>["className"];
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
}
export function ChatInput({
name,
button = "submit",
disabled,
showButton = true,
value,
maxRows = 8,
onSubmit,
onStop,
onChange,
onFocus,
onBlur,
onFilesPaste,
className,
buttonClassName,
}: ChatInputProps) {
const { t } = useTranslation();
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
// Only handle paste if we have an image paste handler and there are files
if (onFilesPaste && event.clipboardData.files.length > 0) {
const files = Array.from(event.clipboardData.files);
// Only prevent default if we found image files to handle
event.preventDefault();
onFilesPaste(files);
}
// For text paste, let the default behavior handle it
};
const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
if (event.dataTransfer.types.includes("Files")) {
setIsDraggingOver(true);
}
};
const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setIsDraggingOver(false);
};
const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
event.preventDefault();
setIsDraggingOver(false);
if (onFilesPaste && event.dataTransfer.files.length > 0) {
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) {
onFilesPaste(files);
}
}
};
const handleSubmitMessage = () => {
const message = value || textareaRef.current?.value || "";
if (message.trim()) {
onSubmit(message);
onChange?.("");
if (textareaRef.current) {
textareaRef.current.value = "";
}
}
};
const handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!disabled &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
handleSubmitMessage();
}
};
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(event.target.value);
};
return (
<div
data-testid="chat-input"
className="flex items-end justify-end grow gap-1 min-h-6 w-full"
>
<TextareaAutosize
ref={textareaRef}
name={name}
placeholder={t(I18nKey.SUGGESTIONS$WHAT_TO_BUILD)}
onKeyDown={handleKeyPress}
onChange={handleChange}
onFocus={onFocus}
onBlur={onBlur}
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
value={value}
minRows={1}
maxRows={maxRows}
data-dragging-over={isDraggingOver}
className={cn(
"grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-hidden ring-0",
"transition-all duration-200 ease-in-out",
isDraggingOver
? "bg-neutral-600/50 rounded-lg px-2"
: "bg-transparent",
className,
)}
/>
{showButton && (
<div className={buttonClassName}>
{button === "submit" && (
<SubmitButton isDisabled={disabled} onClick={handleSubmitMessage} />
)}
{button === "stop" && (
<StopButton isDisabled={disabled} onClick={onStop} />
)}
</div>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { useParams } from "react-router";
import { vi, describe, test, expect, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ChatInterface } from "./chat-interface";
@@ -15,62 +15,16 @@ import { OpenHandsAction } from "#/types/core/actions";
vi.mock("#/context/ws-client-provider");
vi.mock("#/hooks/use-optimistic-user-message");
vi.mock("#/hooks/use-ws-error-message");
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("react-router");
vi.mock("#/hooks/query/use-config");
vi.mock("#/hooks/mutation/use-get-trajectory");
vi.mock("#/hooks/mutation/use-upload-files");
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => ({
providers: [],
}),
}));
vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
useConversationNameContextMenu: () => ({
isOpen: false,
contextMenuRef: { current: null },
handleContextMenu: vi.fn(),
handleClose: vi.fn(),
handleRename: vi.fn(),
handleDelete: vi.fn(),
}),
}));
vi.mock("react-redux", () => ({
useSelector: vi.fn((selector) => {
// Create a mock state object
const mockState = {
agent: {
curAgentState: "AWAITING_USER_INPUT",
},
initialQuery: {
selectedRepository: null,
replayJson: null,
},
conversation: {
messageToSend: null,
files: [],
images: [],
loadingFiles: [],
loadingImages: [],
},
status: {
curStatusMessage: null,
},
};
// Execute the selector function with our mock state
return selector(mockState);
}),
useDispatch: vi.fn(() => vi.fn()),
useSelector: vi.fn(() => ({
curAgentState: "AWAITING_USER_INPUT",
selectedRepository: null,
replayJson: null,
})),
}));
describe("ChatInterface", () => {
@@ -103,7 +57,9 @@ describe("ChatInterface", () => {
setErrorMessage: vi.fn(),
removeErrorMessage: vi.fn(),
});
// useParams is now globally mocked
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
conversationId: "test-id",
});
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
data: { APP_MODE: "local" },
});
@@ -120,12 +76,10 @@ describe("ChatInterface", () => {
});
});
// Helper function to render with QueryClientProvider and Router
// Helper function to render with QueryClientProvider
const renderWithQueryClient = (ui: React.ReactElement) =>
render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
);
test("should show chat suggestions when there are no events", () => {

View File

@@ -1,8 +1,9 @@
import { useSelector, useDispatch } from "react-redux";
import { useSelector } from "react-redux";
import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { createChatMessage } from "#/services/chat-service";
@@ -22,6 +23,8 @@ import { ScrollProvider } from "#/context/scroll-context";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
@@ -30,7 +33,6 @@ import { shouldRenderEvent } from "./event-content-helpers/should-render-event";
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
import { useConfig } from "#/hooks/query/use-config";
import { validateFiles } from "#/utils/file-validation";
import { setMessageToSend } from "#/state/conversation-slice";
function getEntryPoint(
hasRepository: boolean | null,
@@ -42,7 +44,6 @@ function getEntryPoint(
}
export function ChatInterface() {
const dispatch = useDispatch();
const { getErrorMessage } = useWSErrorMessage();
const { send, isLoadingMessages, parsedEvents } = useWsClient();
const { setOptimisticUserMessage, getOptimisticUserMessage } =
@@ -65,10 +66,12 @@ export function ChatInterface() {
"positive" | "negative"
>("positive");
const [feedbackModalIsOpen, setFeedbackModalIsOpen] = React.useState(false);
const [messageToSend, setMessageToSend] = React.useState<string | null>(null);
const { selectedRepository, replayJson } = useSelector(
(state: RootState) => state.initialQuery,
);
const params = useParams();
const { mutate: getTrajectory } = useGetTrajectory();
const { mutateAsync: uploadFiles } = useUploadFiles();
const optimisticUserMessage = getOptimisticUserMessage();
@@ -139,7 +142,7 @@ export function ChatInterface() {
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
setOptimisticUserMessage(content);
dispatch(setMessageToSend(null));
setMessageToSend(null);
};
const handleStop = () => {
@@ -154,6 +157,25 @@ export function ChatInterface() {
setFeedbackPolarity(polarity);
};
const onClickExportTrajectoryButton = () => {
if (!params.conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
return;
}
getTrajectory(params.conversationId, {
onSuccess: async (data) => {
await downloadTrajectory(
params.conversationId ?? t(I18nKey.CONVERSATION$UNKNOWN),
data.trajectory,
);
},
onError: () => {
displayErrorToast(t(I18nKey.CONVERSATION$DOWNLOAD_ERROR));
},
});
};
const isWaitingForUserInput =
curAgentState === AgentState.AWAITING_USER_INPUT ||
curAgentState === AgentState.FINISHED;
@@ -176,19 +198,13 @@ export function ChatInterface() {
!optimisticUserMessage &&
!events.some(
(event) => isOpenHandsAction(event) && event.source === "user",
) && (
<ChatSuggestions
onSuggestionsClick={(message) =>
dispatch(setMessageToSend(message))
}
/>
)}
) && <ChatSuggestions onSuggestionsClick={setMessageToSend} />}
{/* Note: We only hide chat suggestions when there's a user message */}
<div
ref={scrollRef}
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
className="custom-scrollbar-always flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
className="scrollbar scrollbar-thin scrollbar-thumb-gray-400 scrollbar-thumb-rounded-full scrollbar-track-gray-800 hover:scrollbar-thumb-gray-300 flex flex-col grow overflow-y-auto overflow-x-hidden px-4 pt-4 gap-2 fast-smooth-scroll"
>
{isLoadingMessages && (
<div className="flex justify-center">
@@ -214,19 +230,18 @@ export function ChatInterface() {
)}
</div>
<div className="flex flex-col gap-[6px] px-4">
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
{events.length > 0 && (
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
isSaasMode={config?.APP_MODE === "saas"}
/>
)}
<TrajectoryActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
onExportTrajectory={() => onClickExportTrajectoryButton()}
isSaasMode={config?.APP_MODE === "saas"}
/>
<div className="absolute left-1/2 transform -translate-x-1/2 bottom-0">
{curAgentState === AgentState.RUNNING && <TypingIndicator />}
@@ -240,9 +255,13 @@ export function ChatInterface() {
<InteractiveChatBox
onSubmit={handleSendMessage}
onStop={handleStop}
isWaitingForUserInput={isWaitingForUserInput}
hasSubstantiveAgentActions={hasSubstantiveAgentActions}
optimisticUserMessage={!!optimisticUserMessage}
isDisabled={
curAgentState === AgentState.LOADING ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
}
mode={curAgentState === AgentState.RUNNING ? "stop" : "submit"}
value={messageToSend ?? undefined}
onChange={setMessageToSend}
/>
</div>

View File

@@ -9,7 +9,6 @@ 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;
@@ -17,7 +16,6 @@ interface ChatMessageProps {
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
@@ -68,35 +66,17 @@ export function ChatMessage({
"items-center gap-1",
)}
>
{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>
),
)}
{actions?.map((action, index) => (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
))}
<CopyToClipboardButton
isHidden={!isHovering}

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