mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
chuck-debu
...
create-wor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79cc6517e | ||
|
|
5c6bf5f7a0 |
123
.github/workflow-templates/README.md
vendored
Normal file
123
.github/workflow-templates/README.md
vendored
Normal file
@@ -0,0 +1,123 @@
|
||||
# OpenHands GitHub Actions Workflow Templates
|
||||
|
||||
This directory contains workflow templates that make it easy to integrate OpenHands AI agent into your GitHub CI/CD workflows.
|
||||
|
||||
## Available Templates
|
||||
|
||||
### 1. OpenHands Code Review (`openhands-code-review.yml`)
|
||||
Automatically reviews pull requests for code quality, security, and best practices.
|
||||
|
||||
**Triggers:** Pull request opened or updated
|
||||
**Permissions:** `contents: read`, `pull-requests: write`, `issues: write`
|
||||
|
||||
### 2. OpenHands Bug Fix (`openhands-bug-fix.yml`)
|
||||
Automatically investigates and fixes bugs when issues are labeled with 'bug'.
|
||||
|
||||
**Triggers:** Issue labeled with 'bug'
|
||||
**Permissions:** `contents: write`, `pull-requests: write`, `issues: write`
|
||||
|
||||
### 3. OpenHands Documentation (`openhands-documentation.yml`)
|
||||
Keeps project documentation up-to-date by reviewing code changes and updating docs.
|
||||
|
||||
**Triggers:** Weekly schedule, manual dispatch, or code changes
|
||||
**Permissions:** `contents: write`, `pull-requests: write`
|
||||
|
||||
### 4. OpenHands Custom Task (`openhands-custom-task.yml`)
|
||||
Run any custom development task with a manual trigger and custom description.
|
||||
|
||||
**Triggers:** Manual dispatch with task description input
|
||||
**Permissions:** `contents: write`, `pull-requests: write`, `issues: write`
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **OpenHands API Key**: Get your API key from [OpenHands](https://app.all-hands.dev)
|
||||
2. **GitHub Repository**: The templates work with any GitHub repository
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Add API Key Secret**:
|
||||
- Go to your repository's Settings → Secrets and variables → Actions
|
||||
- Add a new repository secret named `OPENHANDS_API_KEY`
|
||||
- Set the value to your OpenHands API key
|
||||
|
||||
2. **Use Templates**:
|
||||
- Go to your repository's Actions tab
|
||||
- Click "New workflow"
|
||||
- Look for "OpenHands" templates in the template gallery
|
||||
- Choose the template that fits your needs
|
||||
- Customize as needed and commit
|
||||
|
||||
### Customization
|
||||
|
||||
All templates can be customized:
|
||||
|
||||
- **Prompts**: Modify the task descriptions to fit your specific needs
|
||||
- **Triggers**: Change when workflows run (schedule, events, manual)
|
||||
- **Timeouts**: Adjust polling timeouts based on task complexity
|
||||
- **Permissions**: Modify based on what actions OpenHands needs to perform
|
||||
|
||||
### Example Customizations
|
||||
|
||||
#### Custom Review Criteria
|
||||
```yaml
|
||||
prompt = '''Please review this pull request focusing on:
|
||||
- Performance optimization opportunities
|
||||
- Database query efficiency
|
||||
- API design consistency
|
||||
- Error handling completeness
|
||||
'''
|
||||
```
|
||||
|
||||
#### Specific Documentation Updates
|
||||
```yaml
|
||||
prompt = '''Update the following documentation:
|
||||
1. API reference in docs/api.md
|
||||
2. Installation guide in README.md
|
||||
3. Code examples in docs/examples/
|
||||
'''
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Workflow Trigger**: GitHub event triggers the workflow
|
||||
2. **Setup**: Installs Python and downloads the OpenHands API helper
|
||||
3. **API Call**: Creates a conversation with OpenHands using your prompt
|
||||
4. **Execution**: OpenHands performs the requested task in your repository
|
||||
5. **Results**: OpenHands may create PRs, comments, or other outputs
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **API Key**: Keep your `OPENHANDS_API_KEY` secret secure
|
||||
- **Permissions**: Templates request minimal required permissions
|
||||
- **Repository Access**: OpenHands will have access to your repository during task execution
|
||||
- **Review Changes**: Always review any PRs or changes made by OpenHands
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Missing API Key**: Ensure `OPENHANDS_API_KEY` is set in repository secrets
|
||||
2. **Permission Errors**: Check that workflow permissions match template requirements
|
||||
3. **Timeout Issues**: Increase timeout values for complex tasks
|
||||
4. **Rate Limits**: OpenHands API has rate limits; space out workflow runs if needed
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the [OpenHands Documentation](https://docs.all-hands.dev)
|
||||
- Review workflow run logs for detailed error messages
|
||||
- Ensure your repository is accessible and has the necessary permissions
|
||||
|
||||
## Contributing
|
||||
|
||||
To improve these templates:
|
||||
|
||||
1. Test changes thoroughly
|
||||
2. Update documentation
|
||||
3. Follow GitHub Actions best practices
|
||||
4. Consider security implications
|
||||
|
||||
## License
|
||||
|
||||
These templates are provided under the same license as the OpenHands project.
|
||||
23
.github/workflow-templates/openhands-bug-fix.properties.json
vendored
Normal file
23
.github/workflow-templates/openhands-bug-fix.properties.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "OpenHands Bug Fix",
|
||||
"description": "Automatically investigate and fix bugs using OpenHands AI agent. Triggered when issues are labeled with 'bug'.",
|
||||
"iconName": "octicon bug",
|
||||
"categories": [
|
||||
"Bug Fix",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Debugging"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$",
|
||||
".*\\.jsx$",
|
||||
".*\\.tsx$",
|
||||
".*\\.java$",
|
||||
".*\\.go$",
|
||||
".*\\.rs$",
|
||||
".*\\.cpp$",
|
||||
".*\\.c$"
|
||||
]
|
||||
}
|
||||
76
.github/workflow-templates/openhands-bug-fix.yml
vendored
Normal file
76
.github/workflow-templates/openhands-bug-fix.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: OpenHands Bug Fix
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
openhands-fix:
|
||||
if: contains(github.event.label.name, 'bug')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Bug Fix
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create bug fix prompt
|
||||
prompt = '''Please investigate and fix the bug described in this GitHub issue:
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
Issue: #${{ github.event.issue.number }}
|
||||
Title: ${{ github.event.issue.title }}
|
||||
|
||||
Issue Description:
|
||||
${{ github.event.issue.body }}
|
||||
|
||||
Please:
|
||||
1. Analyze the issue and identify the root cause
|
||||
2. Implement a fix with proper error handling
|
||||
3. Add or update tests to prevent regression
|
||||
4. Create a pull request with your changes
|
||||
5. Include a clear description of what was fixed and how
|
||||
|
||||
Make sure to follow the project'\''s coding standards and best practices.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands bug fix: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=1200)
|
||||
print(f'Bug fix completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Bug fix may still be running: {e}')
|
||||
"
|
||||
25
.github/workflow-templates/openhands-code-review.properties.json
vendored
Normal file
25
.github/workflow-templates/openhands-code-review.properties.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "OpenHands Code Review",
|
||||
"description": "Automated code review using OpenHands AI agent. Reviews PRs for code quality, security, and best practices.",
|
||||
"iconName": "octicon code-review",
|
||||
"categories": [
|
||||
"Code Quality",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Python",
|
||||
"JavaScript",
|
||||
"TypeScript"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$",
|
||||
".*\\.jsx$",
|
||||
".*\\.tsx$",
|
||||
".*\\.java$",
|
||||
".*\\.go$",
|
||||
".*\\.rs$",
|
||||
".*\\.cpp$",
|
||||
".*\\.c$"
|
||||
]
|
||||
}
|
||||
71
.github/workflow-templates/openhands-code-review.yml
vendored
Normal file
71
.github/workflow-templates/openhands-code-review.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: OpenHands Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
openhands-review:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Code Review
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create review prompt
|
||||
prompt = '''Please review this pull request for:
|
||||
- Code quality and best practices
|
||||
- Security vulnerabilities
|
||||
- Performance considerations
|
||||
- Documentation completeness
|
||||
- Test coverage
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
PR: #${{ github.event.number }}
|
||||
Title: ${{ github.event.pull_request.title }}
|
||||
|
||||
Please provide constructive feedback and suggestions for improvement.
|
||||
If you find any issues, please create a detailed comment explaining the problem and suggesting solutions.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands review: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=600)
|
||||
print(f'Review completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Review may still be running: {e}')
|
||||
"
|
||||
14
.github/workflow-templates/openhands-custom-task.properties.json
vendored
Normal file
14
.github/workflow-templates/openhands-custom-task.properties.json
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "OpenHands Custom Task",
|
||||
"description": "Run any custom development task using OpenHands AI agent. Manually triggered with custom task description.",
|
||||
"iconName": "octicon tools",
|
||||
"categories": [
|
||||
"AI",
|
||||
"Automation",
|
||||
"Custom",
|
||||
"Development"
|
||||
],
|
||||
"filePatterns": [
|
||||
".*"
|
||||
]
|
||||
}
|
||||
78
.github/workflow-templates/openhands-custom-task.yml
vendored
Normal file
78
.github/workflow-templates/openhands-custom-task.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
name: OpenHands Custom Task
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
task_description:
|
||||
description: 'Describe the task you want OpenHands to perform'
|
||||
required: true
|
||||
type: string
|
||||
timeout_minutes:
|
||||
description: 'Timeout in minutes (default: 20)'
|
||||
required: false
|
||||
default: '20'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
openhands-task:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Run OpenHands Custom Task
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create custom task prompt
|
||||
prompt = '''${{ github.event.inputs.task_description }}
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Please complete this task following best practices:
|
||||
- Write clean, well-documented code
|
||||
- Add appropriate tests if needed
|
||||
- Follow the project'\''s coding standards
|
||||
- Create a pull request if changes are made
|
||||
- Provide clear explanations of what was done
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands custom task: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion
|
||||
timeout_seconds = int('${{ github.event.inputs.timeout_minutes }}') * 60
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=timeout_seconds)
|
||||
print(f'Custom task completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Custom task may still be running or timed out: {e}')
|
||||
"
|
||||
19
.github/workflow-templates/openhands-documentation.properties.json
vendored
Normal file
19
.github/workflow-templates/openhands-documentation.properties.json
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "OpenHands Documentation",
|
||||
"description": "Automatically update and maintain project documentation using OpenHands AI agent. Runs weekly and on code changes.",
|
||||
"iconName": "octicon book",
|
||||
"categories": [
|
||||
"Documentation",
|
||||
"AI",
|
||||
"Automation",
|
||||
"Maintenance"
|
||||
],
|
||||
"filePatterns": [
|
||||
"README.md$",
|
||||
".*\\.md$",
|
||||
"docs/.*",
|
||||
".*\\.py$",
|
||||
".*\\.js$",
|
||||
".*\\.ts$"
|
||||
]
|
||||
}
|
||||
81
.github/workflow-templates/openhands-documentation.yml
vendored
Normal file
81
.github/workflow-templates/openhands-documentation.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: OpenHands Documentation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 * * 1' # Weekly on Monday at 2 AM UTC
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ $default-branch ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'lib/**'
|
||||
- '*.py'
|
||||
- '*.js'
|
||||
- '*.ts'
|
||||
|
||||
jobs:
|
||||
openhands-docs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install OpenHands API helper
|
||||
run: |
|
||||
curl -O https://raw.githubusercontent.com/All-Hands-AI/OpenHands/main/scripts/openhands_api.py
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Generate Documentation with OpenHands
|
||||
env:
|
||||
OPENHANDS_API_KEY: ${{ secrets.OPENHANDS_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.append('.')
|
||||
from openhands_api import OpenHandsAPI
|
||||
|
||||
# Create documentation prompt
|
||||
prompt = '''Please review the current codebase and update the project documentation:
|
||||
|
||||
Repository: ${{ github.repository }}
|
||||
|
||||
Tasks:
|
||||
1. Review the README.md and ensure it accurately reflects the current project state
|
||||
2. Update API documentation if there are new endpoints or changes
|
||||
3. Check that code examples in documentation are up-to-date
|
||||
4. Add documentation for any new features or significant changes
|
||||
5. Ensure installation and setup instructions are current
|
||||
6. Update any outdated links or references
|
||||
7. Add or improve code comments where needed
|
||||
|
||||
Please create a pull request with your documentation updates if changes are needed.
|
||||
Focus on clarity, accuracy, and completeness.
|
||||
'''
|
||||
|
||||
# Start OpenHands conversation
|
||||
client = OpenHandsAPI()
|
||||
response = client.create_conversation(
|
||||
initial_user_msg=prompt,
|
||||
repository='${{ github.repository }}'
|
||||
)
|
||||
|
||||
print(f'Started OpenHands documentation update: {response.get(\"conversation_id\", \"Unknown\")}')
|
||||
|
||||
# Poll for completion (optional - remove if you want fire-and-forget)
|
||||
try:
|
||||
final_response = client.poll_until_stopped(response['conversation_id'], timeout=900)
|
||||
print(f'Documentation update completed with status: {final_response.get(\"status\", \"Unknown\")}')
|
||||
except Exception as e:
|
||||
print(f'Documentation update may still be running: {e}')
|
||||
"
|
||||
23
.github/workflows/dispatch-to-docs.yml
vendored
23
.github/workflows/dispatch-to-docs.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: Dispatch to docs repo
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'docs/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
repo: ["All-Hands-AI/docs"]
|
||||
steps:
|
||||
- name: Push to docs repo
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
|
||||
repository: ${{ matrix.repo }}
|
||||
event-type: update
|
||||
client-payload: '{"ref": "${{ github.ref }}", "sha": "${{ github.sha }}", "module": "openhands", "branch": "main"}'
|
||||
29
.github/workflows/enterprise-preview.yml
vendored
29
.github/workflows/enterprise-preview.yml
vendored
@@ -1,29 +0,0 @@
|
||||
# Feature branch preview for enterprise code
|
||||
name: Enterprise Preview
|
||||
|
||||
# Run on PRs labeled
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
# Match ghcr-build.yml, but don't interrupt it.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# This must happen for the PR Docker workflow when the label is present,
|
||||
# and also if it's added after the fact. Thus, it exists in both places.
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event.label.name == 'deploy'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
# This should match the version in ghcr-build.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-d "{\"ref\": \"main\", \"inputs\": {\"openhandsPrNumber\": \"${{ github.event.pull_request.number }}\", \"deployEnvironment\": \"feature\", \"enterpriseImageTag\": \"pr-${{ github.event.pull_request.number }}\" }}" \
|
||||
https://api.github.com/repos/All-Hands-AI/deploy/actions/workflows/deploy.yaml/dispatches
|
||||
7
.github/workflows/fe-unit-tests.yml
vendored
7
.github/workflows/fe-unit-tests.yml
vendored
@@ -42,10 +42,3 @@ jobs:
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
- name: Upload source maps as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-source-maps-${{ github.sha }}
|
||||
path: frontend/build/**/*.map
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
|
||||
76
.github/workflows/frontend-source-maps.yml
vendored
76
.github/workflows/frontend-source-maps.yml
vendored
@@ -1,76 +0,0 @@
|
||||
# Workflow that builds frontend and uploads source maps as artifacts
|
||||
name: Build Frontend Source Maps
|
||||
|
||||
# Run on main branch pushes, releases, and manual triggers
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- ".github/workflows/frontend-source-maps.yml"
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-source-maps:
|
||||
name: Build Frontend and Upload Source Maps
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Build frontend with source maps
|
||||
working-directory: ./frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Upload source maps as artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-source-maps-${{ github.sha }}
|
||||
path: frontend/build/**/*.map
|
||||
retention-days: 90
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload complete build artifacts (for debugging)
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-build-${{ github.sha }}
|
||||
path: |
|
||||
frontend/build/**/*
|
||||
!frontend/build/**/*.map
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create source maps summary
|
||||
run: |
|
||||
echo "## Source Maps Generated 🗺️" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Source maps have been generated and uploaded as artifacts." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Statistics:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Total source map files:** $(find frontend/build -name "*.map" | wc -l)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Total size:** $(du -sh frontend/build/**/*.map 2>/dev/null | tail -1 | cut -f1 || echo 'N/A')" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Commit SHA:** ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Download:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Source maps are available as workflow artifacts and can be downloaded from the Actions tab." >> $GITHUB_STEP_SUMMARY
|
||||
9
.github/workflows/ghcr-build.yml
vendored
9
.github/workflows/ghcr-build.yml
vendored
@@ -176,10 +176,8 @@ jobs:
|
||||
# Do not build enterprise in forks
|
||||
if: github.event.pull_request.head.repo.fork != true
|
||||
steps:
|
||||
- name: Checkout
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
# Set up Docker Buildx for better performance
|
||||
- name: Set up Docker Buildx
|
||||
@@ -237,11 +235,12 @@ jobs:
|
||||
|
||||
enterprise-preview:
|
||||
name: Enterprise preview
|
||||
if: github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'deploy')
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name == 'deploy') ||
|
||||
(github.event_name == 'pull_request' && github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'deploy'))
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: [ghcr_build_enterprise]
|
||||
steps:
|
||||
# This should match the version in enterprise-preview.yml
|
||||
- name: Trigger remote job
|
||||
run: |
|
||||
curl --fail-with-body -sS -X POST \
|
||||
|
||||
70
.github/workflows/mdx-lint.yml
vendored
70
.github/workflows/mdx-lint.yml
vendored
@@ -1,70 +0,0 @@
|
||||
# Workflow that checks MDX format in docs/ folder
|
||||
name: MDX Lint
|
||||
|
||||
# Run on pushes to main and on pull requests that modify docs/ files
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**/*.mdx'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docs/**/*.mdx'
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
mdx-lint:
|
||||
name: Lint MDX files
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install MDX dependencies
|
||||
run: |
|
||||
npm install @mdx-js/mdx@3 glob@10
|
||||
|
||||
- name: Validate MDX files
|
||||
run: |
|
||||
node -e "
|
||||
const {compile} = require('@mdx-js/mdx');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const glob = require('glob');
|
||||
|
||||
async function validateMDXFiles() {
|
||||
const files = glob.sync('docs/**/*.mdx');
|
||||
console.log('Found', files.length, 'MDX files to validate');
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
await compile(content);
|
||||
console.log('✅ MDX parsing successful for', file);
|
||||
} catch (err) {
|
||||
console.error('❌ MDX parsing failed for', file, ':', err.message);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error('\\n❌ Some MDX files have parsing errors. Please fix them before merging.');
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\\n✅ All MDX files are valid!');
|
||||
}
|
||||
}
|
||||
|
||||
validateMDXFiles();
|
||||
"
|
||||
@@ -87,6 +87,8 @@ VSCode Extension:
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
|
||||
If you need to add labels when opening a PR, check the existing labels defined on that repository and select from existing ones. Do not invent your own labels.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
These details may or may not be useful for your current task.
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.56-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.55-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -11,7 +11,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Join our Discord community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
@@ -79,17 +79,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -142,7 +142,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Discord or Github:
|
||||
|
||||
- [Join our Slack workspace](https://dub.sh/openhands) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Discord server](https://discord.gg/ESHStjSjD4) - This is a community-run server for general discussion, questions, and feedback.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
|
||||
10
README_CN.md
10
README_CN.md
@@ -12,7 +12,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="加入我们的Slack社区"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="加入我们的Slack社区"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="加入我们的Discord社区"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="致谢"></a>
|
||||
<br/>
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
@@ -107,7 +107,7 @@ docker run -it --rm --pull=always \
|
||||
OpenHands是一个社区驱动的项目,我们欢迎每个人的贡献。我们大部分沟通
|
||||
通过Slack进行,因此这是开始的最佳场所,但我们也很乐意您通过Discord或Github与我们联系:
|
||||
|
||||
- [加入我们的Slack工作空间](https://dub.sh/openhands) - 这里我们讨论研究、架构和未来发展。
|
||||
- [加入我们的Slack工作空间](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) - 这里我们讨论研究、架构和未来发展。
|
||||
- [加入我们的Discord服务器](https://discord.gg/ESHStjSjD4) - 这是一个社区运营的服务器,用于一般讨论、问题和反馈。
|
||||
- [阅读或发布Github问题](https://github.com/All-Hands-AI/OpenHands/issues) - 查看我们正在处理的问题,或添加您自己的想法。
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
|
||||
<a href="https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Slackコミュニティに参加"></a>
|
||||
<a href="https://discord.gg/ESHStjSjD4"><img src="https://img.shields.io/badge/Discord-Join%20Us-purple?logo=discord&logoColor=white&style=for-the-badge" alt="Discordコミュニティに参加"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="クレジット"></a>
|
||||
<br/>
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
@@ -219,14 +219,6 @@ correct_num = 5
|
||||
api_key = ""
|
||||
model = "gpt-4o"
|
||||
|
||||
# Example routing LLM configuration for multimodal model routing
|
||||
# Uncomment and configure to enable model routing with a secondary model
|
||||
#[llm.secondary_model]
|
||||
#model = "kimi-k2"
|
||||
#api_key = ""
|
||||
#for_routing = true
|
||||
#max_input_tokens = 128000
|
||||
|
||||
|
||||
#################################### Agent ###################################
|
||||
# Configuration for agents (group name starts with 'agent')
|
||||
@@ -488,14 +480,3 @@ type = "noop"
|
||||
|
||||
# Run the runtime sandbox container in privileged mode for use with docker-in-docker
|
||||
#privileged = false
|
||||
|
||||
#################################### Model Routing ############################
|
||||
# Configuration for experimental model routing feature
|
||||
# Enables intelligent switching between different LLM models for specific purposes
|
||||
##############################################################################
|
||||
[model_routing]
|
||||
# Router to use for model selection
|
||||
# Available options:
|
||||
# - "noop_router" (default): No routing, always uses primary LLM
|
||||
# - "multimodal_router": A router that switches between primary and secondary models, depending on whether the input is multimodal or not
|
||||
#router_name = "noop_router"
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.55-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,6 @@ warn_unreachable = True
|
||||
warn_redundant_casts = True
|
||||
no_implicit_optional = True
|
||||
strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -1,36 +1,17 @@
|
||||
# OpenHands Documentation
|
||||
# Setup
|
||||
|
||||
This directory contains the documentation for OpenHands. The documentation is automatically synchronized with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository, which hosts the unified documentation site using Mintlify.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
The documentation files in this directory are automatically included in the main documentation site via Git submodules. When you make changes to documentation in this repository, they will be automatically synchronized to the docs repository.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Automatic Sync**: When documentation changes are pushed to the `main` branch, a GitHub Action automatically notifies the docs repository
|
||||
2. **Submodule Update**: The docs repository updates its submodule reference to include your latest changes
|
||||
3. **Site Rebuild**: Mintlify automatically rebuilds and deploys the documentation site
|
||||
|
||||
## Making Documentation Changes
|
||||
|
||||
Simply edit the documentation files in this directory as usual. The synchronization happens automatically when changes are merged to the main branch.
|
||||
|
||||
## Local Development
|
||||
|
||||
For local documentation development in this repository only:
|
||||
|
||||
```bash
|
||||
```
|
||||
npm install -g mint
|
||||
# or
|
||||
yarn global add mint
|
||||
|
||||
# Preview local changes
|
||||
mint dev
|
||||
```
|
||||
|
||||
For the complete unified documentation site, work with the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository.
|
||||
or
|
||||
|
||||
## Configuration
|
||||
```
|
||||
yarn global add mint
|
||||
```
|
||||
|
||||
The Mintlify configuration (`docs.json`) has been moved to the root of the [All-Hands-AI/docs](https://github.com/All-Hands-AI/docs) repository to enable unified documentation across multiple repositories.
|
||||
# Preview
|
||||
|
||||
```
|
||||
mint dev
|
||||
```
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"slack": "https://dub.sh/openhands",
|
||||
"slack": "https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands",
|
||||
"discord": "https://discord.gg/ESHStjSjD4"
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ This tagging approach allows OpenHands to efficiently manage both development an
|
||||
OpenHands supports both bind mounts and Docker named volumes in SandboxConfig.volumes:
|
||||
|
||||
- Bind mount: "/abs/host/path:/container/path[:mode]"
|
||||
- Named volume: "volume:`<name>`:/container/path[:mode]" or any non-absolute host spec treated as a named volume
|
||||
- Named volume: "volume:<name>:/container/path[:mode]" or any non-absolute host spec treated as a named volume
|
||||
|
||||
Overlay mode (copy-on-write layer) is supported for bind mounts by appending ":overlay" to the mode (e.g., ":ro,overlay").
|
||||
To enable overlay COW, set SANDBOX_VOLUME_OVERLAYS to a writable host directory; per-container upper/work dirs are created under it. If SANDBOX_VOLUME_OVERLAYS is unset, overlay mounts are skipped.
|
||||
|
||||
@@ -89,7 +89,7 @@ If you would like to set things up more systematically, you can:
|
||||
1. **Search existing issues**: Check our [GitHub issues](https://github.com/All-Hands-AI/OpenHands/issues) to see if
|
||||
others have encountered the same problem.
|
||||
2. **Join our community**: Get help from other users and developers:
|
||||
- [Slack community](https://dub.sh/openhands)
|
||||
- [Slack community](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
|
||||
[Troubleshooting](/usage/troubleshooting/troubleshooting).
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ Set environment variables and run the Docker command:
|
||||
|
||||
```bash
|
||||
# Set required environment variables
|
||||
export SANDBOX_VOLUMES="/path/to/workspace:/workspace:rw" # Format: host_path:container_path:mode
|
||||
export SANDBOX_VOLUMES="/path/to/workspace" # See SANDBOX_VOLUMES docs for details
|
||||
export LLM_MODEL="anthropic/claude-sonnet-4-20250514"
|
||||
export LLM_API_KEY="your-api-key"
|
||||
export SANDBOX_SELECTED_REPO="owner/repo-name" # Optional: requires GITHUB_TOKEN
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -119,7 +119,7 @@ When started for the first time, OpenHands will prompt you to set up the LLM pro
|
||||
|
||||
That's it! You can now start using OpenHands with the local LLM server.
|
||||
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
If you encounter any issues, let us know on [Slack](https://join.slack.com/t/openhands-ai/shared_invite/zt-3847of6xi-xuYJIPa6YIPg4ElbDWbtSA) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
|
||||
## Advanced: Alternative LLM Backends
|
||||
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.56-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.55-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.56
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.55
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -10,14 +10,11 @@ from experiments.experiment_versions import (
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.experiments.experiment_manager import ExperimentManager
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
) -> ConversationInitData:
|
||||
def run_conversation_variant_test(user_id, conversation_id, conversation_settings):
|
||||
"""
|
||||
Run conversation variant test and potentially modify the conversation settings
|
||||
based on the PostHog feature flags.
|
||||
@@ -56,7 +53,7 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
@staticmethod
|
||||
def run_config_variant_test(
|
||||
user_id: str, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
):
|
||||
"""
|
||||
Run agent config variant test and potentially modify the OpenHands config
|
||||
based on the current experiment type and PostHog feature flags.
|
||||
|
||||
@@ -14,10 +14,9 @@ from server.constants import (
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
|
||||
def _get_model_variant(user_id, conversation_id) -> str | None:
|
||||
if not EXPERIMENT_CLAUDE4_VS_GPT5:
|
||||
logger.info(
|
||||
'experiment_manager:ab_testing:skipped',
|
||||
@@ -105,11 +104,7 @@ def _get_model_variant(user_id: str | None, conversation_id: str) -> str | None:
|
||||
return enabled_variant
|
||||
|
||||
|
||||
def handle_claude4_vs_gpt5_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
def handle_claude4_vs_gpt5_experiment(user_id, conversation_id, conversation_settings):
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
@@ -125,7 +120,7 @@ def handle_claude4_vs_gpt5_experiment(
|
||||
enabled_variant = _get_model_variant(user_id, conversation_id)
|
||||
|
||||
if not enabled_variant:
|
||||
return conversation_settings
|
||||
return None
|
||||
|
||||
# Set the model based on the feature flag variant
|
||||
if enabled_variant == 'gpt5':
|
||||
|
||||
@@ -11,7 +11,6 @@ from server.constants import IS_FEATURE_ENV
|
||||
from storage.experiment_assignment_store import ExperimentAssignmentStore
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
@@ -115,10 +114,8 @@ def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
|
||||
|
||||
def handle_condenser_max_step_experiment(
|
||||
user_id: str | None,
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
user_id: str, conversation_id: str, conversation_settings
|
||||
):
|
||||
"""
|
||||
Handle the condenser max step experiment for conversation settings.
|
||||
|
||||
|
||||
@@ -390,24 +390,24 @@ class GitHubDataCollector:
|
||||
merged_by = None
|
||||
merge_commit_sha = None
|
||||
if is_merged:
|
||||
merged_by = (pr_data.get('mergedBy') or {}).get('login')
|
||||
merge_commit_sha = (pr_data.get('mergeCommit') or {}).get('oid')
|
||||
merged_by = pr_data.get('mergedBy', {}).get('login')
|
||||
merge_commit_sha = pr_data.get('mergeCommit', {}).get('oid')
|
||||
|
||||
return {
|
||||
'repo_metadata': self._extract_repo_metadata(repo_data),
|
||||
'pr_metadata': {
|
||||
'username': (pr_data.get('author') or {}).get('login'),
|
||||
'number': pr_data.get('number'),
|
||||
'title': pr_data.get('title'),
|
||||
'body': pr_data.get('body'),
|
||||
'username': pr_data.get('author', {}).get('login'),
|
||||
'number': pr_data['number'],
|
||||
'title': pr_data['title'],
|
||||
'body': pr_data['body'],
|
||||
'comments': pr_comments,
|
||||
},
|
||||
'commits': commits,
|
||||
'review_comments': review_comments,
|
||||
'merge_status': {
|
||||
'merged': pr_data.get('merged'),
|
||||
'merged': pr_data['merged'],
|
||||
'merged_by': merged_by,
|
||||
'state': pr_data.get('state'),
|
||||
'state': pr_data['state'],
|
||||
'merge_commit_sha': merge_commit_sha,
|
||||
},
|
||||
'openhands_stats': {
|
||||
|
||||
177
enterprise/poetry.lock
generated
177
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1426,73 +1426,73 @@ yaml = ["pyyaml (>=6.0.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "ddtrace"
|
||||
version = "3.13.0"
|
||||
version = "3.12.4"
|
||||
description = "Datadog APM client library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:12122a8e7089ab40cad2cd6bb51834859aa0a27babf3256a73630e6ee2315455"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:02fab2c444b87f290850b3d750e17ccdf49ace3baf8ff3305e8147f6fdf0dc50"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a003ffa4649dab4971d3557ce2d85eb2c5d335ebc7152196cbf780171fd4b5e1"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52b2458b6f0f4725156d46c6cb5410f98568a61cc890bb270515c9caad3a522d"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:9160222e476e18af95ef687bd548f8e86b3815896bf7cd1d42a9b43005e058e2"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:464e245c2114c722ad4240b73b1c598f83cc1c7bdc9001aec3083f914c1cacc0"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:21901a58e938dbeba0ca6c49b8ba1480d07eea5b057845ae4ff3a706d833137f"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:40e00faced483a3eac0b499cf191a38fbf8bb060a3872029ee3299871f87bdd9"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-win32.whl", hash = "sha256:d15593cb804d74094df1a71167a70136b7616579259ce2b26279f2762354e709"},
|
||||
{file = "ddtrace-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:5de44e7c595d25745665fa1cc44c0f0b4c7ad79be06d0de74f6e0edb2c8ec351"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:68c38ac75cc3668e9284873f5e84c3e104880d68c3891ed13614e0614c46f5b0"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:6d8811c4b7397384aff7e54b7399647f4c1c0e9167792cb45adb2d3553fc20a2"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:029b6e6c50984b1976c6b0970e60184919dab9514441d08683a50a5d52a05326"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8de2a060400ee89422ecfd3269dfd2e113f4f9dae00f6fcd3ed9e53e2223a26a"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:bb0738048ea0e49e6bec9be2bf5c68a24d7ea3b27bf956147378366aacb4ca4b"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:04cf4776c52cfb19914bf6e84242d110197d15426c34e45b14fa63d9085767d5"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c32774e90593ebb264d53d6523b71243b9ba794ae5689e38ad522afddd06c0b"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a01f99b0287c2bbd8b305e0cb54b382eaf2a0fe89ba82f2f68fcbdd9fed040cd"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b37efa3e7b487bd60e6fb89186d98c1ad1727871074f3519c9ca92feea7e5cd0"},
|
||||
{file = "ddtrace-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:112e4d96f02f94247528b65f046c69d360d6eca75b9e7cd2f95fde1c14e2002e"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:13ac5bc306df5719d00a8b1f6925efbb9dd0ba5e121edcc2acfef24c57b3deb5"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:b3bdfc3cabab85f91a4f24264a2d0f6f74984a5b5994c62072c6e3b5e05320f3"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:11b10f8dfadb4b1372aee820be6c22071138ede2ddb32f73486255d5879b283f"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a3d68007602797f280c971a286c3f05bdff66c12a68a3e0bd67cb5bbc1c4a67a"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:abd00a5b83d85a951dd976a59c8673bedacdc1ea9e6acb8e72545f73bddc7879"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5dbe392b2182e6dd617e946cf41da7e3207387b912809ebe8338b794b08750b2"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6b38b4ad9e3f1b3421022587748f6a687ed722eae16033392fc875b5c67d6c5a"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f38a1545495c8db3318621400a3d407db457e3550a397e39cf883f41919e1dc8"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-win32.whl", hash = "sha256:e01bb1b305b777001d310911bd73d1fd88c9c212258caaf65f1422a0dbef1a3b"},
|
||||
{file = "ddtrace-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8dbb9aa23a36599754932e79df28eb07fdd3aaca515297bf58dfcdac608273da"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:397a68e476d8bd9aa14f8c097bc9014510948e76a0110842ab6f5fa1143ad153"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:fab1b06476169e2cf6a098130c44eeb3d9d8205b5a91ae8afdb7d2b4d2d0b0be"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:653f75c3e838366108464f9555120f61ef0589974f346ed2c2c9cb3001d3fc6a"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80f694c3d3984c9bd3bd7818268be7ece02071c67671c6d8c815e6888ae4e78c"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:be16f9c0583767db13403e78ac7ac7b4c103e8b7eaac6deef7c897408f24b940"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c5490a715fbb70ee03840c6a3146c76d7bfa27d5b679ce4c1a7b368eff7dee9f"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:45235a81c828e2d6bdb4ac1bbe55582c190bc27e8820eeae5c0478ea11f1ed81"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a9374a8cf405169a9eab7791cc94d5dc5753eefe806b5bee9909eef3d5e339d"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-win32.whl", hash = "sha256:6bc1648a1c046e6061e29d94d2003c17820cc3a7f1c24322dab654abe9bb30db"},
|
||||
{file = "ddtrace-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8823e95f69dd3fc8a884d092fdc54a3c3078daf0f90e824fceda7e0f26acbc70"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:338932a8511a815d5198ec09d55f6850fcb9c679a1b50a3a28fdc0ff99bd800a"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:c14fe68cfc1c11b9d560a3026e3e5dcdd59b725b6ce79cda66d23a26b37751e6"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fd70631f5c70ccafde14df98a9f807e537222f13d6f03fa08bf1308eaf89301"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09c71f464afb05d7f1a2758112f4feaf2bca39daa22a6c3f75999227eb40e2ec"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:481b13365e3cf100bf35f305bd0680695fa369e67a9ec4e1b41788df62ac1d0b"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0d99ebbef96f406e0436bd21a92354c3c338fc6a8fe85d0a26fe942bc563b721"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:28086003f1c5ce3e84239eea9d624afcc386b38f2115c3438ea49beff84ff861"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f280e80560f5c953bb16b168bed1b6f7d527ef98f81860422500040ee57a7aba"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-win32.whl", hash = "sha256:82f0b76c83e368c686594f42809d727143ee89a879d1a76cde9f75d4cea07cb4"},
|
||||
{file = "ddtrace-3.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:dd7b3a9933b11b2fce4dd4cb34ee465bc3c87024444a2e6a5a653f424bae8e37"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:c1ce2123615e4618050ec7fc96e296283f23c45eddcf3a2fe94386f7513795a4"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:9dae3459edd5cc7a1124596b524b743b1d2bddf4155ca9679c599740ad71546d"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8d36d0cf84a39b29f88dcb06a20fc3f2c7a9eca8eb1fd5d15bc5a51de095962c"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4a55277a3db32fee06030fd0dbf77c2e867541c3e4b65e68e46b03971401173"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:cb97593d9739f0be6647e19edc6fc6998dfba3e78fb9d2df5fef9ebfb117aa85"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f905e5bb2db4c154fca25ded15c3e1d633951db2d6ed2989f630ee3afd589cc0"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:de3ecc6428330117ef063ef6a90326669a9a4cf3e766674228ec384edca52bb1"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eec340ef5152e971dc6ab075945dfa7c41285f8441bea0a78f5f4cd1f6b9aab6"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-win32.whl", hash = "sha256:8c2831f928393f934bfe9f9b5f0eeb22a0f5c88fbebe32cc5106b24409847d6b"},
|
||||
{file = "ddtrace-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:e04f4c41e7216422e9cd101bee70a823f56dddb8333158e1e72b73332e1a311d"},
|
||||
{file = "ddtrace-3.13.0.tar.gz", hash = "sha256:d7d3d82795d29cf2385aa692ee5c65e469ebfa34469941055af66eae2eefa374"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:222dc483f22a065795f473cad6fc6e798ecf9da9f4fc99ca87f1ba70f34d21b1"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:196f114a70b75320876f6861c10435c6d4ea50e0f406328b0862a021c344d002"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4200e8b057b29ce3ba0889a9d423e4d105b0ba35d4bd58ba2670763018909623"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7fc1449d511e04e8b2596eee6d1ad2d3420dff23f6dfd8a899c5e3e03dfe8ba5"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ebae69206957837341cd94bbe78e5242395f7571455dfe911b56ea2f7404ada"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a08cd25234358a2427494d4059ee12afc83e083bad65f2bd62417fd935caa737"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fbe90ff2c914c753116807ddffde9065ecbf9944bdc4932862c3f5835485004d"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1b3be9452bc76f730203b86272f8312c7e195b3125f964900df3f41c39ec0c94"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-win32.whl", hash = "sha256:b331bc0c3000cea1fd70febcf004b5a617c63b9050094f08100891a23638986d"},
|
||||
{file = "ddtrace-3.12.4-cp310-cp310-win_amd64.whl", hash = "sha256:018d19e2a1e7585df65d938ae51c385d673e8001b66827a47e499ade3b227ad2"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:0de9563bad27007fd64059e3b5bb3a791184e39619fdb096044e68a454b4427b"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:d0c5b84d066ca3d60da9636df526382416dae4288f66fcdaca7a2e765ca2f0bd"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff1812b1d7e8344088a978f1d4f621257fe1ad5d8efc07317a3c90c280e5bdc4"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd0ac6ba50d36689bf0eeadc88ce91b60bc863036f3dea90dd5656f39bce3ac4"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f99761f946b2b7cc2ea4cba821a7a94d05a9eb8cd8a3feabdb49eeacc18bb9"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c4f66c48eca7d6759766fcaf24ac3a65e712e62ae7b1f521a7da2b8d7f101849"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:42d46f17baaa5040e4f438544603033af8eeec32067c3712a9e620392d75f484"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0606a07e7d05881f2ef1172f4175733ae3006bfc3c7cfd58b82ea3ed75c914"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-win32.whl", hash = "sha256:efde4b33502f3897993a564ee56d0ea30a65d658d616d16c5ef23c850d0e3417"},
|
||||
{file = "ddtrace-3.12.4-cp311-cp311-win_amd64.whl", hash = "sha256:7d6117fabcd98d3a696d1f80314c9b9e4325b362b31714551efd729a02152ff1"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:734d782d9f64de378f632516554b9da0dfbf54cf1bb7be4bb1085165e7c052ad"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:fbf2543856b4ed5a1d6ac59c82f8c76cef5f4ef65361d59f60ce01db92a4c8d1"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:751ce0410405113286bd558fd402f8a58f5b455cee4deb467ae9ae87e5713547"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd804c06d62926cc18a354987f7d5c1fecd1da30983041d3f98bc402d9d23713"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e55b911d5b9f1bd73731870962809f9089677f4d3736d52587b4ba76eee56962"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8cc90fdcd7f021d06383b88c0e40726706c06088dddd528e31cf3c65a9fea9"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:585b7b26f03c64390c800e180304639b4226c34c533f16bc6cd9c328ee4f727a"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe967af58f2e0033caa977c512a4bfb7af3c6f5ad57e9bdef9241609a4d8a99b"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-win32.whl", hash = "sha256:fe03b8f513513e28c35bc792cd7ef0602b21cbcfe71d17a2dd962aee23e980d9"},
|
||||
{file = "ddtrace-3.12.4-cp312-cp312-win_amd64.whl", hash = "sha256:9fd79c44ecffb36ac5b3168f0f196778ed0dd538beb07961ce10e06b8045af35"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2edf755f4bfd823ce8b560c233cb17137ef79d097bc1ade7914f684b39011bcb"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:6dad7ca193810beb931e81b7430dd074a53bf8f8bd5bdc19acd198d460b2438a"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9de7aa6b6ea3d41f8f20c5e00dd85b2f2b3bb1591f3b7deab5d4c527620c3cb3"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80e0acbbe85365f113bf6e57f77a82f0e0612a7a4cb57f16e9e184748a2bc478"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46de7dd48256d8e347f2ab436644bd8946d3605caedb150eb46327a9f5b005b6"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d5c9ddacecb0072292360813b453129998ca293e13c542fa51771c7734ef03a"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d0b694838e6c7ea2da6de7ccd7b866ec439c49fa40b68ac46f657163cb571d93"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e89a17cdb4b5442b97a219e8522b9c665cf7a5116f7e97049dd145f837bad5b1"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-win32.whl", hash = "sha256:d0b3ec8228950e7ff68c39537630cd12880656d96461ef021d6484b2df8dba84"},
|
||||
{file = "ddtrace-3.12.4-cp313-cp313-win_amd64.whl", hash = "sha256:fad78414731b242e86016a124299f2f41575ccf58444edca777b425dbd9faf0c"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_arm64.whl", hash = "sha256:9f639f70f1689ec1a1049cd64132491ee09bcfe7609d73f8c220e38261611045"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:6b5b150e9d362f7242159dd5a5a7107f1be091282c0ee69301fb7ede60f28d3c"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda3b6ebd275f7f7272f45f4e8ee0e0720c1e217c80140270f8c5e415e11133e"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe644904b44d39a93eb40fb033aef26a03e4096d135ee844b71ed49d1bd647ad"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62a48fc36308919afb1fae22a268a96cff3448f1feb860db97d130498ddfa428"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:77de49365f55033d7e14b544f92d0cae71969b78c4ab8642c3340124e0200739"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:87fbd5126f8339bcb508a52455f58b0c92870a1c3748849a4d6543198b5f8752"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5845d7c2ed46b44e02bd5d36ca7f8e80a4e942683473c867393b9fd4553f9d64"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-win32.whl", hash = "sha256:ebde5af8c5d98f435d7dec960c97151142a4b302e94c20da79ed58fe8a08052e"},
|
||||
{file = "ddtrace-3.12.4-cp38-cp38-win_amd64.whl", hash = "sha256:18dfe9a1a02bfa4ef4f614122135509f454abeff625039b764bc461462ba0923"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e78957120c64bd56ce5592bc10587d7c0d1ca68f21f5b46f6a18dafbc43ad234"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:3936243dc989b8e8e3bb004262abe68a1cc3e0b9356671c01233b84d2c837903"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed76d10787fc288ea94808ce601df243fc3953c7142baefac446015bed799790"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c1d3f7f93146653f8ed06d8cd54030b2c902ceca6de55f6df7f40d23037181e"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f5ab24c82fc7532386b02530f90fed2964718cea296adf6d35fc31bd30d301d"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:30bd9e57923a99d5b4e6562976e9f7307d685caff1544b3d2f7438e6ef8e87e8"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3bf18fd5898940fb7f236b4c9796f0ee517eb755fd0c17965d3a0342f865ee5a"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8ff1c70da37c05a29f0be091b0fdc6bb1d91d448f56861c51df614649441070c"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-win32.whl", hash = "sha256:66c007170698e3d12638d03e80f02e93c3bb3e55e96a7f5517e638056562ec1a"},
|
||||
{file = "ddtrace-3.12.4-cp39-cp39-win_amd64.whl", hash = "sha256:a4f2dabbc95e5c6bf4c43eb141e94021789c81a929588f4000f876f89882c124"},
|
||||
{file = "ddtrace-3.12.4.tar.gz", hash = "sha256:c422977fc4f6e9ba7d4eef9b7e6ce00f8b81c68b034682c6a63eb5c9670e37d8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2325,6 +2325,27 @@ gitdb = ">=4.0.1,<5"
|
||||
doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"]
|
||||
test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""]
|
||||
|
||||
[[package]]
|
||||
name = "google-ai-generativelanguage"
|
||||
version = "0.6.15"
|
||||
description = "Google Ai Generativelanguage API client library"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_ai_generativelanguage-0.6.15-py3-none-any.whl", hash = "sha256:5a03ef86377aa184ffef3662ca28f19eeee158733e45d7947982eb953c6ebb6c"},
|
||||
{file = "google_ai_generativelanguage-0.6.15.tar.gz", hash = "sha256:8f6d9dc4c12b065fe2d0289026171acea5183ebf2d0b11cefe12f3821e159ec3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]}
|
||||
google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev"
|
||||
proto-plus = [
|
||||
{version = ">=1.25.0,<2.0.0dev", markers = "python_version >= \"3.13\""},
|
||||
{version = ">=1.22.3,<2.0.0dev"},
|
||||
]
|
||||
protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev"
|
||||
|
||||
[[package]]
|
||||
name = "google-api-core"
|
||||
version = "2.25.1"
|
||||
@@ -2663,6 +2684,30 @@ websockets = ">=13.0.0,<15.1.0"
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp (<4.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "google-generativeai"
|
||||
version = "0.8.5"
|
||||
description = "Google Generative AI High level API client library and tools."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "google_generativeai-0.8.5-py3-none-any.whl", hash = "sha256:22b420817fb263f8ed520b33285f45976d5b21e904da32b80d4fd20c055123a2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
google-ai-generativelanguage = "0.6.15"
|
||||
google-api-core = "*"
|
||||
google-api-python-client = "*"
|
||||
google-auth = ">=2.15.0"
|
||||
protobuf = "*"
|
||||
pydantic = "*"
|
||||
tqdm = "*"
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["Pillow", "absl-py", "black", "ipython", "nose2", "pandas", "pytype", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "google-resumable-media"
|
||||
version = "2.7.2"
|
||||
@@ -5387,7 +5432,7 @@ google-api-python-client = "^2.164.0"
|
||||
google-auth-httplib2 = "*"
|
||||
google-auth-oauthlib = "*"
|
||||
google-cloud-aiplatform = "*"
|
||||
google-genai = "*"
|
||||
google-generativeai = "*"
|
||||
html2text = "*"
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
ipywidgets = "^8.1.5"
|
||||
@@ -5438,7 +5483,7 @@ whatthepatch = "^1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2.0.0,<3.0.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b (>=1.0.5,<1.8.0)", "modal (>=0.66.26,<1.2.0)", "runloop-api-client (==0.50.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
@@ -10008,4 +10053,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "5771671ef2acc36e7b0931c73fa035ca1d329e8dac6827f7a349e1a569c3fd23"
|
||||
content-hash = "0e611931bd3823ee8b6d832b6ef444868a644e21927a9fb72d4aeaab8170028e"
|
||||
|
||||
@@ -37,7 +37,7 @@ sqlalchemy = { extras = [ "asyncio" ], version = "^2.0.40" }
|
||||
resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
ddtrace = "^3.5.1"
|
||||
posthog = "^4.2.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
|
||||
@@ -275,7 +275,9 @@ class TokenManager:
|
||||
self._check_expiration_and_refresh
|
||||
)
|
||||
if not token_info:
|
||||
logger.info(f'No tokens for user: {username}, identity provider: {idp}')
|
||||
logger.error(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
raise ValueError(
|
||||
f'No tokens for user: {username}, identity provider: {idp}'
|
||||
)
|
||||
|
||||
@@ -178,10 +178,8 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
redis = self._get_redis_client()
|
||||
key = self._get_redis_conversation_key(user_id, sid)
|
||||
starting = await redis.get(key)
|
||||
logger.debug(f'maybe_start_agent_loop starting from redis: {starting}')
|
||||
|
||||
runtime = await self._get_runtime(sid)
|
||||
logger.debug(f'maybe_start_agent_loop runtime: {runtime}')
|
||||
|
||||
nested_url = None
|
||||
session_api_key = None
|
||||
@@ -189,20 +187,15 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
event_store = EventStore(sid, self.file_store, user_id)
|
||||
if runtime:
|
||||
nested_url = self._get_nested_url_for_runtime(runtime['runtime_id'], sid)
|
||||
logger.debug(f'maybe_start_agent_loop nested_url: {nested_url}')
|
||||
session_api_key = runtime.get('session_api_key')
|
||||
logger.debug(f'maybe_start_agent_loop session_api_key: {session_api_key}')
|
||||
status_str = (runtime.get('status') or 'stopped').upper()
|
||||
logger.debug(f'maybe_start_agent_loop status_str: {status_str}')
|
||||
if status_str in ConversationStatus:
|
||||
status = ConversationStatus[status_str]
|
||||
if status is ConversationStatus.STOPPED and starting:
|
||||
logger.debug('maybe_start_agent_loop setting status to starting...')
|
||||
status = ConversationStatus.STARTING
|
||||
|
||||
if status is ConversationStatus.STOPPED:
|
||||
# Mark the agentloop as starting in redis
|
||||
logger.debug('maybe_start_agent_loop starting agent...')
|
||||
await redis.set(key, 1, ex=_REDIS_ENTRY_TIMEOUT_SECONDS)
|
||||
|
||||
# Start the agent loop in the background
|
||||
|
||||
@@ -28,7 +28,6 @@ from evaluation.utils.shared import (
|
||||
prepare_dataset,
|
||||
reset_logger_for_multiprocessing,
|
||||
run_evaluation,
|
||||
update_llm_config_for_completions_logging,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
@@ -37,11 +36,7 @@ from openhands.core.config import (
|
||||
get_llm_config_arg,
|
||||
load_from_toml,
|
||||
)
|
||||
from openhands.core.config.utils import (
|
||||
get_agent_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
)
|
||||
from openhands.core.config.utils import get_agent_config_arg
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import AgentFinishAction, CmdRunAction, MessageAction
|
||||
@@ -62,7 +57,6 @@ AGENT_CLS_TO_INST_SUFFIX = {
|
||||
|
||||
|
||||
def get_config(
|
||||
instance: pd.Series,
|
||||
metadata: EvalMetadata,
|
||||
) -> OpenHandsConfig:
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
@@ -72,24 +66,13 @@ def get_config(
|
||||
sandbox_config=sandbox_config,
|
||||
runtime='docker',
|
||||
)
|
||||
config.set_llm_config(
|
||||
update_llm_config_for_completions_logging(
|
||||
metadata.llm_config, metadata.eval_output_dir, instance['instance_id']
|
||||
)
|
||||
)
|
||||
model_routing_config = get_model_routing_config_arg()
|
||||
model_routing_config.llms_for_routing = (
|
||||
get_llms_for_routing_config()
|
||||
) # Populate with LLMs for routing from config.toml file
|
||||
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
if metadata.agent_config:
|
||||
metadata.agent_config.model_routing = model_routing_config
|
||||
config.set_agent_config(metadata.agent_config, metadata.agent_class)
|
||||
else:
|
||||
logger.info('Agent config not provided, using default settings')
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
agent_config.enable_prompt_extensions = False
|
||||
agent_config.model_routing = model_routing_config
|
||||
|
||||
config_copy = copy.deepcopy(config)
|
||||
load_from_toml(config_copy)
|
||||
@@ -162,7 +145,7 @@ def process_instance(
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
) -> EvalOutput:
|
||||
config = get_config(instance, metadata)
|
||||
config = get_config(metadata)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
|
||||
@@ -47,8 +47,6 @@ from openhands.core.config import (
|
||||
get_agent_config_arg,
|
||||
get_evaluation_parser,
|
||||
get_llm_config_arg,
|
||||
get_llms_for_routing_config,
|
||||
get_model_routing_config_arg,
|
||||
)
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.core.config.utils import get_condenser_config_arg
|
||||
@@ -246,11 +244,6 @@ def get_config(
|
||||
# get 'draft_editor' config if exists
|
||||
config.set_llm_config(get_llm_config_arg('draft_editor'), 'draft_editor')
|
||||
|
||||
model_routing_config = get_model_routing_config_arg()
|
||||
model_routing_config.llms_for_routing = (
|
||||
get_llms_for_routing_config()
|
||||
) # Populate with LLMs for routing from config.toml file
|
||||
|
||||
agent_config = AgentConfig(
|
||||
enable_jupyter=False,
|
||||
enable_browsing=RUN_WITH_BROWSING,
|
||||
@@ -258,10 +251,8 @@ def get_config(
|
||||
enable_mcp=False,
|
||||
condenser=metadata.condenser_config,
|
||||
enable_prompt_extensions=False,
|
||||
model_routing=model_routing_config,
|
||||
)
|
||||
config.set_agent_config(agent_config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
287
frontend/__tests__/components/chat/action-suggestions.test.tsx
Normal file
287
frontend/__tests__/components/chat/action-suggestions.test.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("posthog-js", () => ({
|
||||
default: {
|
||||
capture: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { useSelectorMock } = vi.hoisted(() => ({
|
||||
useSelectorMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-redux", () => ({
|
||||
useSelector: useSelectorMock,
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
ACTION$PUSH_TO_BRANCH: "Push to Branch",
|
||||
ACTION$PUSH_CREATE_PR: "Push & Create PR",
|
||||
ACTION$PUSH_CHANGES_TO_PR: "Push Changes to PR",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({
|
||||
conversationId: "test-conversation-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderActionSuggestions = () =>
|
||||
render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("ActionSuggestions", () => {
|
||||
// Setup mocks for each test
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
},
|
||||
});
|
||||
|
||||
useSelectorMock.mockReturnValue({
|
||||
selectedRepository: "test-repo",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
// @ts-expect-error - only required for testing
|
||||
getConversationSpy.mockResolvedValue({
|
||||
selected_repository: "test-repo",
|
||||
});
|
||||
renderActionSuggestions();
|
||||
|
||||
// Find all buttons with data-testid="suggestion"
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
|
||||
// Check if we have at least 2 buttons
|
||||
expect(buttons.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Check if the buttons contain the expected text
|
||||
const pushButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push to Branch"),
|
||||
);
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
expect(pushButton).toBeInTheDocument();
|
||||
expect(prButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render buttons when GitHub token is not set", () => {
|
||||
renderActionSuggestions();
|
||||
|
||||
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render buttons when no repository is selected", () => {
|
||||
useSelectorMock.mockReturnValue({
|
||||
selectedRepository: null,
|
||||
});
|
||||
|
||||
renderActionSuggestions();
|
||||
|
||||
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
|
||||
// This test verifies that the prompts are different in the component
|
||||
renderActionSuggestions();
|
||||
|
||||
// Get the component instance to access the internal values
|
||||
const pushBranchPrompt =
|
||||
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.";
|
||||
const createPRPrompt =
|
||||
"Please push the changes to GitHub and open a pull request. Please create a meaningful branch name that describes the changes. If a pull request template exists in the repository, please follow it when creating the PR description.";
|
||||
|
||||
// Verify the prompts are different
|
||||
expect(pushBranchPrompt).not.toEqual(createPRPrompt);
|
||||
|
||||
// Verify the PR prompt mentions creating a meaningful branch name
|
||||
expect(createPRPrompt).toContain("meaningful branch name");
|
||||
expect(createPRPrompt).not.toContain("SAME branch name");
|
||||
});
|
||||
|
||||
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
|
||||
// Test case for GitHub repository
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-github",
|
||||
title: "GitHub Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "github",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
// Mock user having both GitHub and Bitbucket tokens
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "github-token",
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
expect(prButton).toBeInTheDocument();
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// The suggestion should mention GitHub, not Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitHub")
|
||||
);
|
||||
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use GitLab terminology when git_provider is gitlab", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-gitlab",
|
||||
title: "GitLab Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "gitlab",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "gitlab-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention GitLab and "merge request" instead of "pull request"
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitLab")
|
||||
);
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("merge request")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-bitbucket",
|
||||
title: "Bitbucket Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "bitbucket",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
});
|
||||
256
frontend/__tests__/components/chat/chat-input.test.tsx
Normal file
256
frontend/__tests__/components/chat/chat-input.test.tsx
Normal 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("こんにちは");
|
||||
});
|
||||
});
|
||||
@@ -1,254 +1,16 @@
|
||||
import {
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
test,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import type { Message } from "#/message";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { ChatInterface } from "#/components/features/chat/chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/use-optimistic-user-message");
|
||||
vi.mock("#/hooks/use-ws-error-message");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-upload-files");
|
||||
|
||||
// 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(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("react-redux", async () => {
|
||||
const actual = await vi.importActual("react-redux");
|
||||
return {
|
||||
...actual,
|
||||
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()),
|
||||
};
|
||||
});
|
||||
|
||||
// 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 />);
|
||||
|
||||
// Helper function to render with QueryClientProvider and Router (for newer tests)
|
||||
const renderWithQueryClient = (
|
||||
ui: React.ReactElement,
|
||||
queryClient: QueryClient,
|
||||
) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("ChatInterface - Chat Suggestions", () => {
|
||||
// Create a new QueryClient for each test
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Default mock implementations
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should show chat suggestions when there are no events", () => {
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Check if ChatSuggestions is rendered
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should show chat suggestions when there are only environment events", () => {
|
||||
const environmentEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "environment",
|
||||
action: "system",
|
||||
args: {
|
||||
content: "source .openhands/setup.sh",
|
||||
tools: null,
|
||||
openhands_version: null,
|
||||
agent_class: null,
|
||||
},
|
||||
message: "Running setup script",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [environmentEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Check if ChatSuggestions is still rendered with environment events
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is a user message", () => {
|
||||
const userEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: {
|
||||
content: "Hello",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
message: "Hello",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [userEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with user events
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is an optimistic user message", () => {
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />, queryClient);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with optimistic user message
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInterface - Empty state", () => {
|
||||
describe("Empty state", () => {
|
||||
const { send: sendMock } = vi.hoisted(() => ({
|
||||
send: vi.fn(),
|
||||
}));
|
||||
@@ -258,52 +20,21 @@ describe("ChatInterface - Empty state", () => {
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
})),
|
||||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks to ensure empty state
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@@ -311,9 +42,9 @@ describe("ChatInterface - Empty state", () => {
|
||||
it.todo("should render suggestions if empty");
|
||||
|
||||
it("should render the default suggestions", () => {
|
||||
renderChatInterfaceWithRouter();
|
||||
renderWithProviders(<ChatInterface />);
|
||||
|
||||
const suggestions = screen.getByTestId("chat-suggestions");
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const repoSuggestions = Object.keys(SUGGESTIONS.repo);
|
||||
|
||||
// check that there are at most 4 suggestions displayed
|
||||
@@ -334,19 +65,18 @@ describe("ChatInterface - Empty state", () => {
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderChatInterfaceWithRouter();
|
||||
renderWithProviders(<ChatInterface />);
|
||||
|
||||
const suggestions = screen.getByTestId("chat-suggestions");
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
const input = screen.getByTestId("chat-input");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
|
||||
// user message loaded to input
|
||||
expect(screen.queryByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("suggestions")).toBeInTheDocument();
|
||||
expect(input).toHaveValue(displayedSuggestions[0].textContent);
|
||||
},
|
||||
);
|
||||
@@ -358,12 +88,11 @@ describe("ChatInterface - Empty state", () => {
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderChatInterfaceWithRouter();
|
||||
const { rerender } = renderWithProviders(<ChatInterface />);
|
||||
|
||||
const suggestions = screen.getByTestId("chat-suggestions");
|
||||
const suggestions = screen.getByTestId("suggestions");
|
||||
const displayedSuggestions = within(suggestions).getAllByRole("button");
|
||||
|
||||
await user.click(displayedSuggestions[0]);
|
||||
@@ -373,13 +102,8 @@ describe("ChatInterface - Empty state", () => {
|
||||
send: sendMock,
|
||||
status: "CONNECTED",
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
}));
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ChatInterface />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMock).toHaveBeenCalledWith(expect.any(String)),
|
||||
@@ -388,7 +112,7 @@ describe("ChatInterface - Empty state", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe.skip("ChatInterface - General functionality", () => {
|
||||
describe.skip("ChatInterface", () => {
|
||||
beforeAll(() => {
|
||||
// mock useScrollToBottom hook
|
||||
vi.mock("#/hooks/useScrollToBottom", () => ({
|
||||
@@ -469,11 +193,7 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
},
|
||||
];
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ChatInterface />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
const imageCarousel = screen.getByTestId("image-carousel");
|
||||
expect(imageCarousel).toBeInTheDocument();
|
||||
@@ -512,11 +232,7 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
pending: true,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ChatInterface />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
expect(screen.getByTestId("continue-action-button")).toBeInTheDocument();
|
||||
});
|
||||
@@ -544,7 +260,10 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
});
|
||||
|
||||
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[] = [
|
||||
{
|
||||
@@ -567,7 +286,10 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
});
|
||||
|
||||
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[] = [
|
||||
{
|
||||
@@ -586,11 +308,7 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
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", {
|
||||
@@ -640,11 +358,7 @@ describe.skip("ChatInterface - General functionality", () => {
|
||||
pending: true,
|
||||
});
|
||||
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<ChatInterface />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
rerender(<ChatInterface />);
|
||||
|
||||
expect(screen.getByTestId("feedback-actions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -64,6 +64,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -75,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
|
||||
@@ -89,6 +91,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -103,6 +106,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={{
|
||||
selected_repository: "org/selectedRepository",
|
||||
@@ -123,6 +127,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -131,14 +136,7 @@ describe("ConversationCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Context menu is always in the DOM but hidden by CSS classes when contextMenuOpen is false
|
||||
const contextMenu = screen.queryByTestId("context-menu");
|
||||
if (contextMenu) {
|
||||
const contextMenuParent = contextMenu.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
}
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
|
||||
const ellipsisButton = screen.getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
@@ -150,6 +148,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -171,6 +170,7 @@ describe("ConversationCard", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -194,6 +194,7 @@ describe("ConversationCard", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={{
|
||||
@@ -222,6 +223,7 @@ describe("ConversationCard", () => {
|
||||
const { rerender } = renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -237,6 +239,7 @@ describe("ConversationCard", () => {
|
||||
rerender(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -249,14 +252,7 @@ describe("ConversationCard", () => {
|
||||
const title = screen.getByTestId("conversation-card-title");
|
||||
|
||||
expect(title).toBeEnabled();
|
||||
// Context menu should be hidden after edit button is clicked (check CSS classes on parent div)
|
||||
const contextMenu = screen.queryByTestId("context-menu");
|
||||
if (contextMenu) {
|
||||
const contextMenuParent = contextMenu.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
}
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
// expect to be focused
|
||||
expect(document.activeElement).toBe(title);
|
||||
|
||||
@@ -265,14 +261,16 @@ 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(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -289,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 () => {
|
||||
@@ -298,6 +297,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onClick={onClick}
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -317,6 +317,7 @@ describe("ConversationCard", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -340,6 +341,7 @@ describe("ConversationCard", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -363,6 +365,7 @@ describe("ConversationCard", () => {
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -382,6 +385,7 @@ describe("ConversationCard", () => {
|
||||
onDelete={onDelete}
|
||||
onChangeTitle={onChangeTitle}
|
||||
showOptions
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
@@ -401,6 +405,7 @@ describe("ConversationCard", () => {
|
||||
renderWithProviders(
|
||||
<ConversationCard
|
||||
onDelete={onDelete}
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository={null}
|
||||
@@ -494,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -130,18 +130,13 @@ describe("ConversationPanel", () => {
|
||||
renderConversationPanel();
|
||||
|
||||
let cards = await screen.findAllByTestId("conversation-card");
|
||||
// Delete button should not be visible initially (context menu is closed)
|
||||
// The context menu is always in the DOM but hidden by CSS classes on the parent div
|
||||
const contextMenuParent = within(cards[0]).queryByTestId(
|
||||
"context-menu",
|
||||
)?.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
expect(
|
||||
within(cards[0]).queryByTestId("delete-button"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
const deleteButton = within(cards[0]).getByTestId("delete-button");
|
||||
const deleteButton = screen.getByTestId("delete-button");
|
||||
|
||||
// Click the first delete button
|
||||
await user.click(deleteButton);
|
||||
@@ -227,7 +222,7 @@ describe("ConversationPanel", () => {
|
||||
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
const deleteButton = within(cards[0]).getByTestId("delete-button");
|
||||
const deleteButton = screen.getByTestId("delete-button");
|
||||
|
||||
// Click the first delete button
|
||||
await user.click(deleteButton);
|
||||
@@ -373,7 +368,7 @@ describe("ConversationPanel", () => {
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Stop button should be available for RUNNING conversation
|
||||
const stopButton = within(cards[0]).getByTestId("stop-button");
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
// Click the stop button
|
||||
@@ -449,7 +444,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const stopButton = within(cards[0]).getByTestId("stop-button");
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
|
||||
// Click the stop button
|
||||
await user.click(stopButton);
|
||||
@@ -529,51 +524,29 @@ describe("ConversationPanel", () => {
|
||||
);
|
||||
await user.click(runningEllipsisButton);
|
||||
|
||||
expect(within(cards[0]).getByTestId("stop-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Wait for context menu to close (check CSS classes on parent div)
|
||||
await waitFor(() => {
|
||||
const contextMenuParent = within(cards[0]).queryByTestId(
|
||||
"context-menu",
|
||||
)?.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
});
|
||||
|
||||
// Test STARTING conversation - should show stop button
|
||||
const startingEllipsisButton = within(cards[1]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(startingEllipsisButton);
|
||||
|
||||
expect(within(cards[1]).getByTestId("stop-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Wait for context menu to close (check CSS classes on parent div)
|
||||
await waitFor(() => {
|
||||
const contextMenuParent = within(cards[1]).queryByTestId(
|
||||
"context-menu",
|
||||
)?.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
});
|
||||
|
||||
// Test STOPPED conversation - should NOT show stop button
|
||||
const stoppedEllipsisButton = within(cards[2]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(stoppedEllipsisButton);
|
||||
|
||||
expect(
|
||||
within(cards[2]).queryByTestId("stop-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show edit button in context menu", async () => {
|
||||
@@ -587,10 +560,10 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Edit button should be visible within the first card's context menu
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
// 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 () => {
|
||||
@@ -603,8 +576,8 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Click edit button within the first card's context menu
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
// Click edit button
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Should find input field instead of title text
|
||||
@@ -636,7 +609,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title
|
||||
@@ -667,7 +640,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title and press Enter
|
||||
@@ -696,7 +669,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title with extra whitespace
|
||||
@@ -709,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 () => {
|
||||
@@ -725,7 +701,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Clear the title completely
|
||||
@@ -735,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 () => {
|
||||
@@ -755,7 +734,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title
|
||||
@@ -785,23 +764,16 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Verify context menu is open within the first card
|
||||
const contextMenu = within(cards[0]).getByTestId("context-menu");
|
||||
// Verify context menu is open
|
||||
const contextMenu = screen.getByTestId("context-menu");
|
||||
expect(contextMenu).toBeInTheDocument();
|
||||
|
||||
// Click edit button within the first card's context menu
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
// Click edit button
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Wait for context menu to close after edit button click (check CSS classes on parent div)
|
||||
await waitFor(() => {
|
||||
const contextMenuParent = within(cards[0]).queryByTestId(
|
||||
"context-menu",
|
||||
)?.parentElement;
|
||||
if (contextMenuParent) {
|
||||
expect(contextMenuParent).toHaveClass("opacity-0", "invisible");
|
||||
}
|
||||
});
|
||||
// Verify context menu is closed
|
||||
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not call API when title is unchanged", async () => {
|
||||
@@ -818,14 +790,15 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Don't change the title, just blur
|
||||
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
|
||||
await user.tab();
|
||||
|
||||
// Verify API was NOT called with the same title (since handleConversationTitleChange will always be called)
|
||||
expect(updateConversationSpy).not.toHaveBeenCalledWith("1", {
|
||||
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
|
||||
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
|
||||
title: "Conversation 1",
|
||||
});
|
||||
});
|
||||
@@ -844,7 +817,7 @@ describe("ConversationPanel", () => {
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const editButton = within(cards[0]).getByTestId("edit-button");
|
||||
const editButton = screen.getByTestId("edit-button");
|
||||
await user.click(editButton);
|
||||
|
||||
// Edit the title with special characters
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,389 +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();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-start-conversation", () => ({
|
||||
useStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-stop-conversation", () => ({
|
||||
useStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
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$STOP_RUNTIME: "Stop Runtime",
|
||||
COMMON$START_RUNTIME: "Start Runtime",
|
||||
};
|
||||
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 stop conversation mutation when stop server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockStopConversationMutate.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(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
});
|
||||
|
||||
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 the component)
|
||||
expect(mockStopConversationMutate).toHaveBeenCalledWith({
|
||||
conversationId: "test-conversation-id",
|
||||
});
|
||||
});
|
||||
|
||||
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("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 Runtime")).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(
|
||||
"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 Runtime",
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -169,12 +169,11 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should render the 'add github repos' link in dropdown if saas mode and github provider is set", async () => {
|
||||
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
@@ -186,42 +185,16 @@ describe("RepoConnector", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// First select the GitHub provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then open the repository dropdown
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// The "Add GitHub repos" link should be in the dropdown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("HOME$ADD_GITHUB_REPOS")).toBeInTheDocument();
|
||||
});
|
||||
await screen.findByText("HOME$ADD_GITHUB_REPOS");
|
||||
});
|
||||
|
||||
it("should not render the 'add github repos' link if github provider is not set", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE and APP_SLUG
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
APP_SLUG: "openhands",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
@@ -233,76 +206,22 @@ describe("RepoConnector", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// First select the GitLab provider (not GitHub)
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitLab"));
|
||||
|
||||
// Then open the repository dropdown
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// The "Add GitHub repos" link should NOT be in the dropdown for GitLab
|
||||
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render the 'add github repos' link in dropdown if oss mode", async () => {
|
||||
it("should not render the 'add git(hub|lab) repos' links if oss mode", async () => {
|
||||
const getConfiSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return the APP_MODE
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue({
|
||||
data: MOCK_RESPOSITORIES,
|
||||
nextPage: null,
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
// First select the GitHub provider
|
||||
const providerDropdown = await waitFor(() =>
|
||||
screen.getByTestId("git-provider-dropdown"),
|
||||
);
|
||||
await userEvent.click(providerDropdown);
|
||||
await userEvent.click(screen.getByText("GitHub"));
|
||||
|
||||
// Then open the repository dropdown
|
||||
const repoInput = await waitFor(() =>
|
||||
screen.getByTestId("git-repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(repoInput);
|
||||
|
||||
// The "Add GitHub repos" link should NOT be in the dropdown for OSS mode
|
||||
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Add GitHub repos")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Add GitLab repos")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should create a conversation and redirect with the selected repo when pressing the launch button", async () => {
|
||||
|
||||
@@ -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} />, {
|
||||
@@ -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("dropdown-error"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("Failed to load data"),
|
||||
).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,32 @@ 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");
|
||||
|
||||
// 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 +251,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 +264,15 @@ 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");
|
||||
|
||||
// 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
@@ -105,6 +102,16 @@ describe("TaskCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable the launch button and update text content when creating a conversation", async () => {
|
||||
renderTaskCard();
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(launchButton).toHaveTextContent(/Loading/i);
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should navigate to the conversation page after creating a conversation", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
createConversationSpy.mockResolvedValue({
|
||||
@@ -118,7 +125,7 @@ describe("TaskCard", () => {
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
session_api_key: null
|
||||
});
|
||||
|
||||
renderTaskCard();
|
||||
|
||||
@@ -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,28 +23,6 @@ vi.mock("react-i18next", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the dependencies for useShouldShowUserFeatures
|
||||
vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => ({
|
||||
data: true,
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: () => ({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [{ id: "github", name: "GitHub" }],
|
||||
isLoading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderTaskSuggestions = () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -97,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner";
|
||||
|
||||
// Mock react-i18next
|
||||
@@ -29,11 +28,7 @@ describe("MaintenanceBanner", () => {
|
||||
it("renders maintenance banner with formatted time", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const { container } = render(<MaintenanceBanner startTime={startTime} />);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
@@ -53,11 +48,7 @@ describe("MaintenanceBanner", () => {
|
||||
it("handles invalid date gracefully", () => {
|
||||
const invalidTime = "invalid-date";
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={invalidTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MaintenanceBanner startTime={invalidTime} />);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
@@ -67,11 +58,7 @@ describe("MaintenanceBanner", () => {
|
||||
it("click on dismiss button removes banner", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
render(<MaintenanceBanner startTime={startTime} />);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
@@ -87,11 +74,7 @@ describe("MaintenanceBanner", () => {
|
||||
const startTime = "2024-01-15T10:00:00-05:00"; // EST timestamp
|
||||
const nextStartTime = "2025-01-15T10:00:00-05:00"; // EST timestamp
|
||||
|
||||
const { rerender } = render(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={startTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const { rerender } = render(<MaintenanceBanner startTime={startTime} />);
|
||||
|
||||
// Check if the banner is rendered
|
||||
const banner = screen.queryByTestId("maintenance-banner");
|
||||
@@ -102,12 +85,27 @@ describe("MaintenanceBanner", () => {
|
||||
});
|
||||
|
||||
expect(banner).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<MemoryRouter>
|
||||
<MaintenanceBanner startTime={nextStartTime} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
rerender(<MaintenanceBanner startTime={nextStartTime} />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -95,8 +95,8 @@ describe("HomeScreen", () => {
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "fake-token",
|
||||
gitlab: "fake-token",
|
||||
github: null,
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -118,144 +118,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({ branches: [
|
||||
{ name: "main", commit_sha: "123", protected: false },
|
||||
{ name: "develop", commit_sha: "456", protected: false },
|
||||
], has_next_page: false, current_page: 1, per_page: 30, total_count: 2 });
|
||||
|
||||
// Select a repository to enable the repo launch button
|
||||
await selectRepository("octocat/hello-world");
|
||||
@@ -269,7 +152,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");
|
||||
|
||||
@@ -351,6 +235,16 @@ describe("HomeScreen", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => {
|
||||
renderHomeScreen();
|
||||
|
||||
const taskSuggestions = screen.queryByTestId("task-suggestions");
|
||||
const repoConnector = screen.getByTestId("repo-connector");
|
||||
|
||||
expect(taskSuggestions).not.toBeInTheDocument();
|
||||
expect(repoConnector).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Settings 404", () => {
|
||||
@@ -371,10 +265,11 @@ describe("Settings 404", () => {
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have the correct advanced settings link that opens in a new window", async () => {
|
||||
it("should navigate to the settings screen when clicking the advanced settings button", async () => {
|
||||
const error = createAxiosNotFoundErrorObject();
|
||||
getSettingsSpy.mockRejectedValue(error);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderHomeScreen();
|
||||
|
||||
const settingsScreen = screen.queryByTestId("settings-screen");
|
||||
@@ -383,16 +278,16 @@ describe("Settings 404", () => {
|
||||
const settingsModal = await screen.findByTestId("ai-config-modal");
|
||||
expect(settingsModal).toBeInTheDocument();
|
||||
|
||||
const advancedSettingsLink = await screen.findByTestId(
|
||||
const advancedSettingsButton = await screen.findByTestId(
|
||||
"advanced-settings-link",
|
||||
);
|
||||
await user.click(advancedSettingsButton);
|
||||
|
||||
// The advanced settings link should be an anchor tag that opens in a new window
|
||||
const linkElement = advancedSettingsLink.querySelector("a");
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
expect(linkElement).toHaveAttribute("href", "/settings");
|
||||
expect(linkElement).toHaveAttribute("target", "_blank");
|
||||
expect(linkElement).toHaveAttribute("rel", "noreferrer noopener");
|
||||
const settingsScreenAfter = await screen.findByTestId("settings-screen");
|
||||
expect(settingsScreenAfter).toBeInTheDocument();
|
||||
|
||||
const settingsModalAfter = screen.queryByTestId("ai-config-modal");
|
||||
expect(settingsModalAfter).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not open the settings modal if GET /settings fails but is SaaS mode", async () => {
|
||||
|
||||
@@ -86,21 +86,28 @@ describe("Content", () => {
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render add secret button in saas mode", async () => {
|
||||
it("should render a button to connect with git if they havent already in saas", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
|
||||
// @ts-expect-error - only return the config we need
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
renderSecretsSettings();
|
||||
|
||||
// In SAAS mode, getSecrets is called and add secret button should be available
|
||||
// In SAAS mode, getSecrets is still called because the user is authenticated
|
||||
await waitFor(() => expect(getSecretsSpy).toHaveBeenCalled());
|
||||
const button = await screen.findByTestId("add-secret-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("connect-git-button")).not.toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("add-secret-button")).not.toBeInTheDocument(),
|
||||
);
|
||||
const button = await screen.findByTestId("connect-git-button");
|
||||
expect(button).toHaveAttribute("href", "/settings/integrations");
|
||||
});
|
||||
|
||||
it("should render an empty table when there are no existing secrets", async () => {
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useSuggestedTasks } from "../src/hooks/query/use-suggested-tasks";
|
||||
import { useShouldShowUserFeatures } from "../src/hooks/use-should-show-user-features";
|
||||
|
||||
// Mock the dependencies
|
||||
vi.mock("../src/hooks/use-should-show-user-features");
|
||||
vi.mock("#/api/suggestions-service/suggestions-service.api", () => ({
|
||||
SuggestionsService: {
|
||||
getSuggestedTasks: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockUseShouldShowUserFeatures = vi.mocked(useShouldShowUserFeatures);
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useSuggestedTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default to disabled
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("should be disabled when useShouldShowUserFeatures returns false", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useSuggestedTasks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it("should be enabled when useShouldShowUserFeatures returns true", () => {
|
||||
mockUseShouldShowUserFeatures.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useSuggestedTasks(), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// When enabled, the query should be loading/fetching
|
||||
expect(result.current.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
1138
frontend/package-lock.json
generated
1138
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.56.0",
|
||||
"version": "0.55.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -17,28 +17,27 @@
|
||||
"@reduxjs/toolkit": "^2.9.0",
|
||||
"@stripe/react-stripe-js": "^4.0.0",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-query": "^5.87.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.9",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@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.5.2",
|
||||
"i18next": "^25.4.2",
|
||||
"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",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.261.7",
|
||||
"posthog-js": "^1.261.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -47,15 +46,14 @@
|
||||
"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-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.4",
|
||||
"web-vitals": "^5.1.0",
|
||||
"ws": "^8.18.2"
|
||||
@@ -98,7 +96,7 @@
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
|
||||
5
frontend/postcss.config.js
Normal file
5
frontend/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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()
|
||||
|
||||
@@ -21,13 +21,13 @@ 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 { 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";
|
||||
@@ -373,15 +373,11 @@ class OpenHands {
|
||||
conversationId: string,
|
||||
providers?: Provider[],
|
||||
): Promise<Conversation | null> {
|
||||
const err = new Error("Call stack:");
|
||||
console.log("startConversation...");
|
||||
console.log(err.stack);
|
||||
const { data } = await openHands.post<Conversation | null>(
|
||||
`/api/conversations/${conversationId}/start`,
|
||||
providers ? { providers_set: providers } : {},
|
||||
);
|
||||
|
||||
console.log(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -144,11 +144,6 @@ export interface GetMicroagentPromptResponse {
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export interface IOption<T> {
|
||||
label: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface CreateMicroagent {
|
||||
repo: string;
|
||||
git_provider?: Provider;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -6,12 +6,9 @@ export function EmptyBrowserMessage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<IoIosGlobe size={113} />
|
||||
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{" "}
|
||||
{t(I18nKey.BROWSER$NO_PAGE_LOADED)}
|
||||
</span>
|
||||
<div className="flex flex-col items-center h-full justify-center">
|
||||
<IoIosGlobe size={100} />
|
||||
{t(I18nKey.BROWSER$NO_PAGE_LOADED)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
89
frontend/src/components/features/chat/action-suggestions.tsx
Normal file
89
frontend/src/components/features/chat/action-suggestions.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import posthog from "posthog-js";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
interface ActionSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ActionSuggestions({
|
||||
onSuggestionsClick,
|
||||
}: ActionSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { providers } = useUserProviders();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [hasPullRequest, setHasPullRequest] = React.useState(false);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
// Use the git_provider from the conversation, not the user's authenticated providers
|
||||
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,
|
||||
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 justify-center w-full">
|
||||
{!hasPullRequest ? (
|
||||
<>
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: t(I18nKey.ACTION$PUSH_TO_BRANCH),
|
||||
value: terms.pushToBranch,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
posthog.capture("push_to_branch_button_clicked");
|
||||
onSuggestionsClick(value);
|
||||
}}
|
||||
/>
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: t(I18nKey.ACTION$PUSH_CREATE_PR),
|
||||
value: terms.createPR,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
onSuggestionsClick(value);
|
||||
setHasPullRequest(true);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SuggestionItem
|
||||
suggestion={{
|
||||
label: t(I18nKey.ACTION$PUSH_CHANGES_TO_PR),
|
||||
value: terms.pushToPR,
|
||||
}}
|
||||
onClick={(value) => {
|
||||
posthog.capture("push_to_pr_button_clicked");
|
||||
onSuggestionsClick(value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/features/chat/chat-input.tsx
Normal file
149
frontend/src/components/features/chat/chat-input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal file
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useParams } from "react-router";
|
||||
import { vi, describe, test, expect, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ChatInterface } from "./chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/use-optimistic-user-message");
|
||||
vi.mock("#/hooks/use-ws-error-message");
|
||||
vi.mock("react-router");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-upload-files");
|
||||
vi.mock("react-redux", () => ({
|
||||
useSelector: vi.fn(() => ({
|
||||
curAgentState: "AWAITING_USER_INPUT",
|
||||
selectedRepository: null,
|
||||
replayJson: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("ChatInterface", () => {
|
||||
// Create a new QueryClient for each test
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Default mock implementations
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
conversationId: "test-id",
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to render with QueryClientProvider
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
|
||||
test("should show chat suggestions when there are no events", () => {
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is rendered
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should show chat suggestions when there are only environment events", () => {
|
||||
const environmentEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "environment",
|
||||
action: "system",
|
||||
args: {
|
||||
content: "source .openhands/setup.sh",
|
||||
tools: null,
|
||||
openhands_version: null,
|
||||
agent_class: null,
|
||||
},
|
||||
message: "Running setup script",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [environmentEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is still rendered with environment events
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is a user message", () => {
|
||||
const userEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: {
|
||||
content: "Hello",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
message: "Hello",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [userEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with user events
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is an optimistic user message", () => {
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with optimistic user message
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -17,10 +18,13 @@ import { TypingIndicator } from "./typing-indicator";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { ChatSuggestions } from "./chat-suggestions";
|
||||
import { ActionSuggestions } from "./action-suggestions";
|
||||
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";
|
||||
@@ -29,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,
|
||||
@@ -41,7 +44,6 @@ function getEntryPoint(
|
||||
}
|
||||
|
||||
export function ChatInterface() {
|
||||
const dispatch = useDispatch();
|
||||
const { getErrorMessage } = useWSErrorMessage();
|
||||
const { send, isLoadingMessages, parsedEvents } = useWsClient();
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
@@ -64,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();
|
||||
@@ -138,7 +142,7 @@ export function ChatInterface() {
|
||||
|
||||
send(createChatMessage(prompt, imageUrls, uploadedFiles, timestamp));
|
||||
setOptimisticUserMessage(content);
|
||||
dispatch(setMessageToSend(null));
|
||||
setMessageToSend(null);
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
@@ -153,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;
|
||||
@@ -170,24 +193,18 @@ export function ChatInterface() {
|
||||
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
<div className="h-full flex flex-col justify-between pr-0 md:pr-4 relative">
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{!hasSubstantiveAgentActions &&
|
||||
!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">
|
||||
@@ -203,21 +220,28 @@ export function ChatInterface() {
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
<div className="flex justify-between relative">
|
||||
{events.length > 0 && (
|
||||
<TrajectoryActions
|
||||
onPositiveFeedback={() =>
|
||||
onClickShareFeedbackActionButton("positive")
|
||||
}
|
||||
onNegativeFeedback={() =>
|
||||
onClickShareFeedbackActionButton("negative")
|
||||
}
|
||||
isSaasMode={config?.APP_MODE === "saas"}
|
||||
{isWaitingForUserInput &&
|
||||
hasSubstantiveAgentActions &&
|
||||
!optimisticUserMessage && (
|
||||
<ActionSuggestions
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-[6px] px-4 pb-4">
|
||||
<div className="flex justify-between relative">
|
||||
<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 />}
|
||||
@@ -231,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>
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import PlayIcon from "#/icons/play-solid.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface ChatResumeAgentButtonProps {
|
||||
onAgentResumed: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ChatResumeAgentButton({
|
||||
onAgentResumed,
|
||||
disabled = false,
|
||||
}: ChatResumeAgentButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAgentResumed}
|
||||
data-testid="play-button"
|
||||
disabled={disabled}
|
||||
className={cn("cursor-pointer", disabled && "cursor-not-allowed")}
|
||||
>
|
||||
<PlayIcon className="block max-w-none w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export interface ChatSendButtonProps {
|
||||
buttonClassName: string;
|
||||
handleSubmit: () => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export function ChatSendButton({
|
||||
buttonClassName,
|
||||
handleSubmit,
|
||||
disabled,
|
||||
}: ChatSendButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-full border border-white size-[35px]",
|
||||
disabled
|
||||
? "cursor-not-allowed border-neutral-600"
|
||||
: "cursor-pointer hover:bg-[#959CB2]",
|
||||
buttonClassName,
|
||||
)}
|
||||
data-name="arrow-up-circle-fill"
|
||||
data-testid="submit-button"
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ArrowUp color={disabled ? "#959CB2" : "white"} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import PauseIcon from "#/icons/pause.svg?react";
|
||||
|
||||
export interface ChatStopButtonProps {
|
||||
handleStop: () => void;
|
||||
}
|
||||
|
||||
export function ChatStopButton({ handleStop }: ChatStopButtonProps) {
|
||||
return (
|
||||
<button type="button" onClick={handleStop} data-testid="stop-button">
|
||||
<PauseIcon className="block max-w-none w-4 h-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Suggestions } from "#/components/features/suggestions/suggestions";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import BuildIt from "#/icons/build-it.svg?react";
|
||||
import { SUGGESTIONS } from "#/utils/suggestions";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface ChatSuggestionsProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -13,38 +10,27 @@ interface ChatSuggestionsProps {
|
||||
|
||||
export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const shouldHideSuggestions = useSelector(
|
||||
(state: RootState) => state.conversation.shouldHideSuggestions,
|
||||
);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!shouldHideSuggestions && (
|
||||
<motion.div
|
||||
data-testid="chat-suggestions"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
className="absolute top-0 left-0 right-0 bottom-[151px] flex flex-col items-center justify-center pointer-events-auto"
|
||||
>
|
||||
<div className="flex flex-col items-center p-4 rounded-xl w-full">
|
||||
<BuildIt width={86} height={103} />
|
||||
<span className="text-[32px] font-bold leading-5 text-white pt-4 pb-6">
|
||||
{t(I18nKey.LANDING$TITLE)}
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={onSuggestionsClick}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
data-testid="chat-suggestions"
|
||||
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center p-4 bg-tertiary rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
{t(I18nKey.LANDING$TITLE)}
|
||||
</span>
|
||||
</div>
|
||||
<Suggestions
|
||||
suggestions={Object.entries(SUGGESTIONS.repo)
|
||||
.slice(0, 4)
|
||||
.map(([label, value]) => ({
|
||||
label,
|
||||
value,
|
||||
}))}
|
||||
onSuggestionClick={onSuggestionsClick}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,442 +0,0 @@
|
||||
import React, { useRef, useCallback, useState, useEffect } from "react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { ChatSendButton } from "./chat-send-button";
|
||||
import { ChatAddFileButton } from "./chat-add-file-button";
|
||||
import { cn, isMobileDevice } from "#/utils/utils";
|
||||
import { useAutoResize } from "#/hooks/use-auto-resize";
|
||||
import { DragOver } from "./drag-over";
|
||||
import { UploadedFiles } from "./uploaded-files";
|
||||
import { Tools } from "../controls/tools";
|
||||
import {
|
||||
clearAllFiles,
|
||||
setShouldHideSuggestions,
|
||||
setSubmittedMessage,
|
||||
setMessageToSend,
|
||||
setIsRightPanelShown,
|
||||
} from "#/state/conversation-slice";
|
||||
import { CHAT_INPUT } from "#/utils/constants";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export interface CustomChatInputProps {
|
||||
disabled?: boolean;
|
||||
showButton?: boolean;
|
||||
conversationStatus?: ConversationStatus | null;
|
||||
onSubmit: (message: string) => void;
|
||||
onStop?: () => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
onFilesPaste?: (files: File[]) => void;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
buttonClassName?: React.HTMLAttributes<HTMLButtonElement>["className"];
|
||||
}
|
||||
|
||||
export function CustomChatInput({
|
||||
disabled = false,
|
||||
showButton = true,
|
||||
conversationStatus = null,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onFilesPaste,
|
||||
className = "",
|
||||
buttonClassName = "",
|
||||
}: CustomChatInputProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [isGripVisible, setIsGripVisible] = useState(false);
|
||||
|
||||
const { messageToSend, submittedMessage, hasRightPanelToggled } = useSelector(
|
||||
(state: RootState) => state.conversation,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Disable input when conversation is stopped
|
||||
const isConversationStopped = conversationStatus === "STOPPED";
|
||||
const isDisabled = disabled || isConversationStopped;
|
||||
|
||||
// Listen to submittedMessage state changes
|
||||
useEffect(() => {
|
||||
if (!submittedMessage || disabled) {
|
||||
return;
|
||||
}
|
||||
onSubmit(submittedMessage);
|
||||
dispatch(setSubmittedMessage(null));
|
||||
}, [submittedMessage, disabled, onSubmit, dispatch]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const chatInputRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||
const gripRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Save current input value when drawer state changes
|
||||
useEffect(() => {
|
||||
if (chatInputRef.current) {
|
||||
const currentText = chatInputRef.current?.innerText || "";
|
||||
// Dispatch to save current input value when drawer state changes
|
||||
dispatch(setMessageToSend(currentText));
|
||||
dispatch(setIsRightPanelShown(hasRightPanelToggled));
|
||||
}
|
||||
}, [hasRightPanelToggled, dispatch]);
|
||||
|
||||
// Helper function to check if contentEditable is truly empty
|
||||
const isContentEmpty = useCallback((): boolean => {
|
||||
if (!chatInputRef.current) return true;
|
||||
const text =
|
||||
chatInputRef.current.innerText || chatInputRef.current.textContent || "";
|
||||
return text.trim() === "";
|
||||
}, []);
|
||||
|
||||
// Helper function to properly clear contentEditable for placeholder display
|
||||
const clearEmptyContent = useCallback((): void => {
|
||||
if (chatInputRef.current && isContentEmpty()) {
|
||||
chatInputRef.current.innerHTML = "";
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
}, [isContentEmpty]);
|
||||
|
||||
// Drag state management callbacks
|
||||
const handleDragStart = useCallback(() => {
|
||||
// Keep grip visible during drag by adding a CSS class
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.add("opacity-100");
|
||||
gripRef.current.classList.remove("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Restore hover-based visibility
|
||||
if (gripRef.current) {
|
||||
gripRef.current.classList.remove("opacity-100");
|
||||
gripRef.current.classList.add("opacity-0");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle click on top edge area to toggle grip visibility
|
||||
const handleTopEdgeClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsGripVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
// Callback to handle height changes and manage suggestions visibility
|
||||
const handleHeightChange = useCallback(
|
||||
(height: number) => {
|
||||
// Hide suggestions when input height exceeds the threshold
|
||||
const shouldHideChatSuggestions = height > CHAT_INPUT.HEIGHT_THRESHOLD;
|
||||
dispatch(setShouldHideSuggestions(shouldHideChatSuggestions));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Use the auto-resize hook with height change callback
|
||||
const {
|
||||
smartResize,
|
||||
handleGripMouseDown,
|
||||
handleGripTouchStart,
|
||||
increaseHeightForEmptyContent,
|
||||
} = useAutoResize(chatInputRef, {
|
||||
minHeight: 20,
|
||||
maxHeight: 400,
|
||||
onHeightChange: handleHeightChange,
|
||||
onGripDragStart: handleDragStart,
|
||||
onGripDragEnd: handleDragEnd,
|
||||
value: messageToSend ?? undefined,
|
||||
enableManualResize: true,
|
||||
});
|
||||
|
||||
// Cleanup: reset suggestions visibility when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
dispatch(setShouldHideSuggestions(false));
|
||||
dispatch(clearAllFiles());
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
// Function to add files and notify parent
|
||||
const addFiles = useCallback(
|
||||
(files: File[]) => {
|
||||
// Call onFilesPaste if provided with the new files
|
||||
if (onFilesPaste && files.length > 0) {
|
||||
onFilesPaste(files);
|
||||
}
|
||||
},
|
||||
[onFilesPaste],
|
||||
);
|
||||
|
||||
// File icon click handler
|
||||
const handleFileIconClick = () => {
|
||||
if (!isDisabled && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Drag and drop event handlers
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
// Only remove drag-over class if we're leaving the container entirely
|
||||
if (!chatContainerRef.current?.contains(e.relatedTarget as Node)) {
|
||||
setIsDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
if (isDisabled) return;
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
addFiles(files);
|
||||
};
|
||||
|
||||
// Send button click handler
|
||||
const handleSubmit = () => {
|
||||
const message = chatInputRef.current?.innerText || "";
|
||||
|
||||
if (message.trim()) {
|
||||
onSubmit(message);
|
||||
|
||||
// Clear the input
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
}
|
||||
};
|
||||
|
||||
// Resume agent button click handler
|
||||
const handleResumeAgent = () => {
|
||||
const message = chatInputRef.current?.innerText || "continue";
|
||||
|
||||
onSubmit(message.trim());
|
||||
|
||||
// Clear the input
|
||||
if (chatInputRef.current) {
|
||||
chatInputRef.current.textContent = "";
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
|
||||
// Reset height and show suggestions again
|
||||
smartResize();
|
||||
};
|
||||
|
||||
// Handle stop button click
|
||||
const handleStop = () => {
|
||||
if (onStop) {
|
||||
onStop();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle input events
|
||||
const handleInput = () => {
|
||||
smartResize();
|
||||
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent();
|
||||
}
|
||||
|
||||
// Ensure cursor stays visible when content is scrollable
|
||||
if (!chatInputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (
|
||||
!range.getBoundingClientRect ||
|
||||
!chatInputRef.current.getBoundingClientRect
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = range.getBoundingClientRect();
|
||||
const inputRect = chatInputRef.current.getBoundingClientRect();
|
||||
|
||||
// If cursor is below the visible area, scroll to show it
|
||||
if (rect.bottom > inputRect.bottom) {
|
||||
chatInputRef.current.scrollTop =
|
||||
chatInputRef.current.scrollHeight - chatInputRef.current.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle paste events to clean up formatting
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get plain text from clipboard
|
||||
const text = e.clipboardData.getData("text/plain");
|
||||
|
||||
// Insert plain text
|
||||
document.execCommand("insertText", false, text);
|
||||
|
||||
// Trigger resize
|
||||
setTimeout(smartResize, 0);
|
||||
};
|
||||
|
||||
// Handle key events
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isContentEmpty()) {
|
||||
e.preventDefault();
|
||||
increaseHeightForEmptyContent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Original submit logic - only for desktop without shift key
|
||||
if (!isMobileDevice() && !e.shiftKey && !disabled) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle blur events to ensure placeholder shows when empty
|
||||
const handleBlur = () => {
|
||||
// Clear empty content to ensure placeholder shows
|
||||
if (chatInputRef.current) {
|
||||
clearEmptyContent();
|
||||
}
|
||||
|
||||
// Call the original onBlur callback if provided
|
||||
if (onBlur) {
|
||||
onBlur();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
accept="*/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileInputChange}
|
||||
data-testid="upload-image-input"
|
||||
/>
|
||||
|
||||
{/* Container with grip */}
|
||||
<div className="relative w-full">
|
||||
{/* Top edge hover area - invisible area that triggers grip visibility */}
|
||||
<div
|
||||
className="absolute -top-[12px] left-0 w-full h-6 lg:h-3 z-20 group"
|
||||
id="resize-grip"
|
||||
onClick={handleTopEdgeClick}
|
||||
>
|
||||
{/* Resize Grip - appears on hover of top edge area, when dragging, or when clicked */}
|
||||
<div
|
||||
ref={gripRef}
|
||||
className={cn(
|
||||
"absolute top-[4px] left-0 w-full h-[3px] bg-white cursor-ns-resize z-10 transition-opacity duration-200",
|
||||
isGripVisible
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover:opacity-100",
|
||||
)}
|
||||
onMouseDown={handleGripMouseDown}
|
||||
onTouchStart={handleGripTouchStart}
|
||||
style={{ userSelect: "none" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chat Input Component */}
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag Over UI */}
|
||||
{isDragOver && <DragOver />}
|
||||
|
||||
<UploadedFiles />
|
||||
{/* Main Input Row */}
|
||||
<div className="box-border content-stretch flex flex-row items-end justify-between p-0 relative shrink-0 w-full pb-[18px] gap-2">
|
||||
<div className="basis-0 box-border content-stretch flex flex-row gap-4 grow items-end justify-start min-h-px min-w-px p-0 relative shrink-0">
|
||||
<ChatAddFileButton
|
||||
disabled={disabled}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
/>
|
||||
|
||||
{/* Chat Input Area */}
|
||||
<div
|
||||
className="box-border content-stretch flex flex-row items-center justify-start min-h-6 p-0 relative shrink-0 flex-1"
|
||||
data-name="Text & caret"
|
||||
>
|
||||
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
|
||||
<div
|
||||
ref={chatInputRef}
|
||||
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
|
||||
contentEditable
|
||||
data-placeholder={t("SUGGESTIONS$WHAT_TO_BUILD")}
|
||||
data-testid="chat-input"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Button */}
|
||||
{showButton && (
|
||||
<ChatSendButton
|
||||
buttonClassName={cn(buttonClassName, "translate-y-[3px]")}
|
||||
handleSubmit={handleSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus conversationStatus={conversationStatus} />
|
||||
</div>
|
||||
<AgentStatus
|
||||
handleStop={handleStop}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ImageIcon from "#/icons/image.svg?react";
|
||||
import ArrowDownCurveIcon from "#/icons/arrow-down-curve.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function DragOver() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="drag-over">
|
||||
<div className="drag-over-content-wrapper">
|
||||
<div className="relative">
|
||||
<ImageIcon
|
||||
width={36}
|
||||
height={36}
|
||||
className="rotate-[-27deg] absolute top-[-40px] left-[-10px]"
|
||||
/>
|
||||
<ArrowDownCurveIcon
|
||||
width={16}
|
||||
height={16}
|
||||
className="absolute top-[-16px] left-[-20px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="drag-over-content">
|
||||
<p>{t(I18nKey.COMMON$DROP_YOUR_FILES_HERE)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import BranchIcon from "#/icons/u-code-branch.svg?react";
|
||||
import { constructBranchUrl, cn } from "#/utils/utils";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitExternalLinkIcon } from "./git-external-link-icon";
|
||||
|
||||
interface GitControlBarBranchButtonProps {
|
||||
selectedBranch: string | null | undefined;
|
||||
selectedRepository: string | null | undefined;
|
||||
gitProvider: Provider | null | undefined;
|
||||
}
|
||||
|
||||
export function GitControlBarBranchButton({
|
||||
selectedBranch,
|
||||
selectedRepository,
|
||||
gitProvider,
|
||||
}: GitControlBarBranchButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasBranch = selectedBranch && selectedRepository && gitProvider;
|
||||
const branchUrl = hasBranch
|
||||
? constructBranchUrl(gitProvider, selectedRepository, selectedBranch)
|
||||
: undefined;
|
||||
|
||||
const buttonText = hasBranch ? selectedBranch : t(I18nKey.COMMON$NO_BRANCH);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={hasBranch ? branchUrl : undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit max-w-none flex-shrink-0 max-w-[108px] truncate relative",
|
||||
hasBranch
|
||||
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
|
||||
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[108px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center justify-start">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<BranchIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-normal text-white text-sm leading-5 truncate",
|
||||
hasBranch && "max-w-[70px]",
|
||||
)}
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBranch && <GitExternalLinkIcon />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import PRIcon from "#/icons/u-pr.svg?react";
|
||||
import { cn, getCreatePRPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface GitControlBarPrButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
isEnabled: boolean;
|
||||
hasRepository: boolean;
|
||||
currentGitProvider: Provider;
|
||||
}
|
||||
|
||||
export function GitControlBarPrButton({
|
||||
onSuggestionsClick,
|
||||
isEnabled,
|
||||
hasRepository,
|
||||
currentGitProvider,
|
||||
}: GitControlBarPrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isButtonEnabled = isEnabled && providersAreSet && hasRepository;
|
||||
|
||||
const handlePrClick = () => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePrClick}
|
||||
disabled={!isButtonEnabled}
|
||||
className={cn(
|
||||
"flex flex-row gap-1 items-center justify-center px-2 py-1 rounded-[100px] w-[126px] min-w-[126px] h-7",
|
||||
isButtonEnabled
|
||||
? "bg-[#25272D] hover:bg-[#454545] cursor-pointer"
|
||||
: "bg-[rgba(71,74,84,0.50)] cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<PRIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 max-w-[126px] truncate"
|
||||
title={t(I18nKey.COMMON$PULL_REQUEST)}
|
||||
>
|
||||
{t(I18nKey.COMMON$PULL_REQUEST)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowDownIcon from "#/icons/u-arrow-down.svg?react";
|
||||
import { cn, getGitPullPrompt } from "#/utils/utils";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface GitControlBarPullButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarPullButton({
|
||||
onSuggestionsClick,
|
||||
isEnabled,
|
||||
}: GitControlBarPullButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const hasRepository = conversation?.selected_repository;
|
||||
const isButtonEnabled = isEnabled && providersAreSet && hasRepository;
|
||||
|
||||
const handlePullClick = () => {
|
||||
posthog.capture("pull_button_clicked");
|
||||
onSuggestionsClick(getGitPullPrompt());
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePullClick}
|
||||
disabled={!isButtonEnabled}
|
||||
className={cn(
|
||||
"flex flex-row gap-1 items-center justify-center px-0.5 py-1 rounded-[100px] w-[76px] min-w-[76px]",
|
||||
isButtonEnabled
|
||||
? "bg-[#25272D] hover:bg-[#454545] cursor-pointer"
|
||||
: "bg-[rgba(71,74,84,0.50)] cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<ArrowDownIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 max-w-[76px] truncate"
|
||||
title={t(I18nKey.COMMON$PULL)}
|
||||
>
|
||||
{t(I18nKey.COMMON$PULL)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowUpIcon from "#/icons/u-arrow-up.svg?react";
|
||||
import { cn, getGitPushPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface GitControlBarPushButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
isEnabled: boolean;
|
||||
hasRepository: boolean;
|
||||
currentGitProvider: Provider;
|
||||
}
|
||||
|
||||
export function GitControlBarPushButton({
|
||||
onSuggestionsClick,
|
||||
isEnabled,
|
||||
hasRepository,
|
||||
currentGitProvider,
|
||||
}: GitControlBarPushButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
const isButtonEnabled = isEnabled && providersAreSet && hasRepository;
|
||||
|
||||
const handlePushClick = () => {
|
||||
posthog.capture("push_button_clicked");
|
||||
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePushClick}
|
||||
disabled={!isButtonEnabled}
|
||||
className={cn(
|
||||
"flex flex-row gap-1 items-center justify-center px-2 py-1 rounded-[100px] w-[77px] min-w-[77px]",
|
||||
isButtonEnabled
|
||||
? "bg-[#25272D] hover:bg-[#454545] cursor-pointer"
|
||||
: "bg-[rgba(71,74,84,0.50)] cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
<ArrowUpIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
<div
|
||||
className="font-normal text-white text-sm leading-5 max-w-[77px] truncate"
|
||||
title={t(I18nKey.COMMON$PUSH)}
|
||||
>
|
||||
{t(I18nKey.COMMON$PUSH)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { constructRepositoryUrl, cn } from "#/utils/utils";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
|
||||
import { GitExternalLinkIcon } from "./git-external-link-icon";
|
||||
import RepoForkedIcon from "#/icons/repo-forked.svg?react";
|
||||
|
||||
interface GitControlBarRepoButtonProps {
|
||||
selectedRepository: string | null | undefined;
|
||||
gitProvider: Provider | null | undefined;
|
||||
}
|
||||
|
||||
export function GitControlBarRepoButton({
|
||||
selectedRepository,
|
||||
gitProvider,
|
||||
}: GitControlBarRepoButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hasRepository = selectedRepository && gitProvider;
|
||||
|
||||
const repositoryUrl = hasRepository
|
||||
? constructRepositoryUrl(gitProvider, selectedRepository)
|
||||
: undefined;
|
||||
|
||||
const buttonText = hasRepository
|
||||
? selectedRepository
|
||||
: t(I18nKey.COMMON$NO_REPO_CONNECTED);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={hasRepository ? repositoryUrl : undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(
|
||||
"group flex flex-row items-center justify-between gap-2 pl-2.5 pr-2.5 py-1 rounded-[100px] w-fit flex-shrink-0 max-w-[170px] truncate relative",
|
||||
hasRepository
|
||||
? "border border-[#525252] bg-transparent hover:border-[#454545] cursor-pointer"
|
||||
: "border border-[rgba(71,74,84,0.50)] bg-transparent cursor-not-allowed min-w-[170px]",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2 items-center justify-start">
|
||||
<div className="w-3 h-3 flex items-center justify-center">
|
||||
{hasRepository ? (
|
||||
<GitProviderIcon
|
||||
gitProvider={gitProvider as Provider}
|
||||
className="w-3 h-3 inline-flex"
|
||||
/>
|
||||
) : (
|
||||
<RepoForkedIcon width={12} height={12} color="white" />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"font-normal text-white text-sm leading-5 truncate",
|
||||
hasRepository && "max-w-[100px]",
|
||||
)}
|
||||
title={buttonText}
|
||||
>
|
||||
{buttonText}
|
||||
</div>
|
||||
</div>
|
||||
{hasRepository && <GitExternalLinkIcon />}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface GitControlBarTooltipWrapperProps {
|
||||
tooltipMessage: string;
|
||||
testId: string;
|
||||
children: React.ReactNode;
|
||||
shouldShowTooltip: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBarTooltipWrapper({
|
||||
children,
|
||||
tooltipMessage,
|
||||
testId,
|
||||
shouldShowTooltip,
|
||||
}: GitControlBarTooltipWrapperProps) {
|
||||
if (!shouldShowTooltip) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipButton
|
||||
tooltip={tooltipMessage}
|
||||
ariaLabel={tooltipMessage}
|
||||
testId={testId}
|
||||
placement="top"
|
||||
className="hover:opacity-100"
|
||||
tooltipClassName="bg-white text-black"
|
||||
showArrow
|
||||
>
|
||||
{children}
|
||||
</TooltipButton>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GitControlBarRepoButton } from "./git-control-bar-repo-button";
|
||||
import { GitControlBarBranchButton } from "./git-control-bar-branch-button";
|
||||
import { GitControlBarPullButton } from "./git-control-bar-pull-button";
|
||||
import { GitControlBarPushButton } from "./git-control-bar-push-button";
|
||||
import { GitControlBarPrButton } from "./git-control-bar-pr-button";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { GitControlBarTooltipWrapper } from "./git-control-bar-tooltip-wrapper";
|
||||
|
||||
interface GitControlBarProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
isWaitingForUserInput: boolean;
|
||||
hasSubstantiveAgentActions: boolean;
|
||||
optimisticUserMessage: boolean;
|
||||
}
|
||||
|
||||
export function GitControlBar({
|
||||
onSuggestionsClick,
|
||||
isWaitingForUserInput,
|
||||
hasSubstantiveAgentActions,
|
||||
optimisticUserMessage,
|
||||
}: GitControlBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
const gitProvider = conversation?.git_provider as Provider;
|
||||
const selectedBranch = conversation?.selected_branch;
|
||||
|
||||
// Button is enabled when the agent is waiting for user input, has substantive actions, and no optimistic message
|
||||
const isButtonEnabled =
|
||||
isWaitingForUserInput &&
|
||||
hasSubstantiveAgentActions &&
|
||||
!optimisticUserMessage;
|
||||
|
||||
const hasRepository = !!selectedRepository;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row gap-2.5 items-center overflow-x-auto flex-wrap md:flex-nowrap relative scrollbar-hide">
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-repo-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarRepoButton
|
||||
selectedRepository={selectedRepository}
|
||||
gitProvider={gitProvider}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-branch-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarBranchButton
|
||||
selectedBranch={selectedBranch}
|
||||
selectedRepository={selectedRepository}
|
||||
gitProvider={gitProvider}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
{hasRepository ? (
|
||||
<>
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-pull-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarPullButton
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
isEnabled={isButtonEnabled}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-push-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarPushButton
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
isEnabled={isButtonEnabled}
|
||||
hasRepository={hasRepository}
|
||||
currentGitProvider={gitProvider}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
|
||||
<GitControlBarTooltipWrapper
|
||||
tooltipMessage={t(I18nKey.COMMON$GIT_TOOLS_DISABLED_CONTENT)}
|
||||
testId="git-control-bar-pr-button-tooltip"
|
||||
shouldShowTooltip={!hasRepository}
|
||||
>
|
||||
<GitControlBarPrButton
|
||||
onSuggestionsClick={onSuggestionsClick}
|
||||
isEnabled={isButtonEnabled}
|
||||
hasRepository={hasRepository}
|
||||
currentGitProvider={gitProvider}
|
||||
/>
|
||||
</GitControlBarTooltipWrapper>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import LinkExternalIcon from "#/icons/link-external.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface GitExternalLinkIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function GitExternalLinkIcon({ className }: GitExternalLinkIconProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-3 h-3 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity duration-300 absolute right-0 top-1/2 -translate-y-1/2 h-full w-10.5 pr-2.5 justify-end git-external-link-icon",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LinkExternalIcon width={12} height={12} color="white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface GitScrollButtonProps {
|
||||
direction: "left" | "right";
|
||||
onClick: () => void;
|
||||
ariaLabel: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function GitScrollButton({
|
||||
direction,
|
||||
onClick,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: GitScrollButtonProps) {
|
||||
const isLeft = direction === "left";
|
||||
|
||||
const baseClasses =
|
||||
"flex items-center h-[28px] w-[30.6px] min-w-[30.6px] cursor-pointer relative z-10 bg-gradient-to-l from-transparent from-[7.76%] to-[#0D0F11] to-[80.02%]";
|
||||
|
||||
const pseudoCommonElementClasses =
|
||||
"before:content-[''] before:absolute before:inset-y-0 before:w-[30px] before:pointer-events-none before:z-[5] before:backdrop-blur-[1px]";
|
||||
|
||||
const pseudoCommonGradientClasses =
|
||||
"before:from-[rgba(13,15,17,0.98)] before:from-0% before:via-[rgba(13,15,17,0.85)] before:via-[25%] before:via-[rgba(13,15,17,0.6)] before:via-[50%] before:via-[rgba(13,15,17,0.2)] before:via-[80%] before:to-transparent before:to-[100%]";
|
||||
|
||||
const pseudoElementClasses = isLeft
|
||||
? "justify-start before:right-[-30px] before:bg-gradient-to-r"
|
||||
: "justify-end before:left-[-30px] before:bg-gradient-to-l";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
pseudoCommonElementClasses,
|
||||
pseudoCommonGradientClasses,
|
||||
pseudoElementClasses,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,171 +1,110 @@
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import React from "react";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ImageCarousel } from "../images/image-carousel";
|
||||
import { UploadImageInput } from "../images/upload-image-input";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { isFileImage } from "#/utils/is-file-image";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { validateFiles } from "#/utils/file-validation";
|
||||
import { CustomChatInput } from "./custom-chat-input";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { GitControlBar } from "./git-control-bar";
|
||||
import {
|
||||
addImages,
|
||||
addFiles,
|
||||
clearAllFiles,
|
||||
addFileLoading,
|
||||
removeFileLoading,
|
||||
addImageLoading,
|
||||
removeImageLoading,
|
||||
} from "#/state/conversation-slice";
|
||||
import { processFiles, processImages } from "#/utils/file-processing";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
isDisabled?: boolean;
|
||||
mode?: "stop" | "submit";
|
||||
onSubmit: (message: string, images: File[], files: File[]) => void;
|
||||
onStop: () => void;
|
||||
isWaitingForUserInput: boolean;
|
||||
hasSubstantiveAgentActions: boolean;
|
||||
optimisticUserMessage: boolean;
|
||||
value?: string;
|
||||
onChange?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function InteractiveChatBox({
|
||||
isDisabled,
|
||||
mode = "submit",
|
||||
onSubmit,
|
||||
onStop,
|
||||
isWaitingForUserInput,
|
||||
hasSubstantiveAgentActions,
|
||||
optimisticUserMessage,
|
||||
value,
|
||||
onChange,
|
||||
}: InteractiveChatBoxProps) {
|
||||
const dispatch = useDispatch();
|
||||
const curAgentState = useSelector(
|
||||
(state: RootState) => state.agent.curAgentState,
|
||||
);
|
||||
const images = useSelector((state: RootState) => state.conversation.images);
|
||||
const files = useSelector((state: RootState) => state.conversation.files);
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [images, setImages] = React.useState<File[]>([]);
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
|
||||
// Helper function to validate and filter files
|
||||
const validateAndFilterFiles = (selectedFiles: File[]) => {
|
||||
const handleUpload = (selectedFiles: File[]) => {
|
||||
// Validate files before adding them
|
||||
const validation = validateFiles(selectedFiles, [...images, ...files]);
|
||||
|
||||
if (!validation.isValid) {
|
||||
displayErrorToast(`Error: ${validation.errorMessage}`);
|
||||
return null;
|
||||
return; // Don't add any files if validation fails
|
||||
}
|
||||
|
||||
// Filter valid files by type
|
||||
const validFiles = selectedFiles.filter((f) => !isFileImage(f));
|
||||
const validImages = selectedFiles.filter((f) => isFileImage(f));
|
||||
|
||||
return { validFiles, validImages };
|
||||
setFiles((prevFiles) => [...prevFiles, ...validFiles]);
|
||||
setImages((prevImages) => [...prevImages, ...validImages]);
|
||||
};
|
||||
|
||||
// Helper function to show loading indicators for files
|
||||
const showLoadingIndicators = (validFiles: File[], validImages: File[]) => {
|
||||
validFiles.forEach((file) => dispatch(addFileLoading(file.name)));
|
||||
validImages.forEach((image) => dispatch(addImageLoading(image.name)));
|
||||
const removeElementByIndex = (array: Array<File>, index: number) => {
|
||||
const newArray = [...array];
|
||||
newArray.splice(index, 1);
|
||||
return newArray;
|
||||
};
|
||||
|
||||
// Helper function to handle successful file processing results
|
||||
const handleSuccessfulFiles = (fileResults: { successful: File[] }) => {
|
||||
if (fileResults.successful.length > 0) {
|
||||
dispatch(addFiles(fileResults.successful));
|
||||
fileResults.successful.forEach((file) =>
|
||||
dispatch(removeFileLoading(file.name)),
|
||||
);
|
||||
}
|
||||
const handleRemoveFile = (index: number) => {
|
||||
setFiles(removeElementByIndex(files, index));
|
||||
};
|
||||
|
||||
// Helper function to handle successful image processing results
|
||||
const handleSuccessfulImages = (imageResults: { successful: File[] }) => {
|
||||
if (imageResults.successful.length > 0) {
|
||||
dispatch(addImages(imageResults.successful));
|
||||
imageResults.successful.forEach((image) =>
|
||||
dispatch(removeImageLoading(image.name)),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to handle failed file processing results
|
||||
const handleFailedFiles = (
|
||||
fileResults: { failed: { file: File; error: Error }[] },
|
||||
imageResults: { failed: { file: File; error: Error }[] },
|
||||
) => {
|
||||
fileResults.failed.forEach(({ file, error }) => {
|
||||
dispatch(removeFileLoading(file.name));
|
||||
displayErrorToast(
|
||||
`Failed to process file ${file.name}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
|
||||
imageResults.failed.forEach(({ file, error }) => {
|
||||
dispatch(removeImageLoading(file.name));
|
||||
displayErrorToast(
|
||||
`Failed to process image ${file.name}: ${error.message}`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// Helper function to clear loading states on error
|
||||
const clearLoadingStates = (validFiles: File[], validImages: File[]) => {
|
||||
validFiles.forEach((file) => dispatch(removeFileLoading(file.name)));
|
||||
validImages.forEach((image) => dispatch(removeImageLoading(image.name)));
|
||||
};
|
||||
|
||||
const handleUpload = async (selectedFiles: File[]) => {
|
||||
// Step 1: Validate and filter files
|
||||
const result = validateAndFilterFiles(selectedFiles);
|
||||
if (!result) return;
|
||||
|
||||
const { validFiles, validImages } = result;
|
||||
|
||||
// Step 2: Show loading indicators immediately
|
||||
showLoadingIndicators(validFiles, validImages);
|
||||
|
||||
// Step 3: Process files using REAL FileReader
|
||||
try {
|
||||
const [fileResults, imageResults] = await Promise.all([
|
||||
processFiles(validFiles),
|
||||
processImages(validImages),
|
||||
]);
|
||||
|
||||
// Step 4: Handle successful results
|
||||
handleSuccessfulFiles(fileResults);
|
||||
handleSuccessfulImages(imageResults);
|
||||
|
||||
// Step 5: Handle failed results
|
||||
handleFailedFiles(fileResults, imageResults);
|
||||
} catch (error) {
|
||||
// Clear loading states and show error
|
||||
clearLoadingStates(validFiles, validImages);
|
||||
displayErrorToast("An unexpected error occurred while processing files");
|
||||
}
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(removeElementByIndex(images, index));
|
||||
};
|
||||
|
||||
const handleSubmit = (message: string) => {
|
||||
onSubmit(message, images, files);
|
||||
dispatch(clearAllFiles());
|
||||
setFiles([]);
|
||||
setImages([]);
|
||||
if (message) {
|
||||
onChange?.("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuggestionsClick = (suggestion: string) => {
|
||||
handleSubmit(suggestion);
|
||||
};
|
||||
|
||||
const isDisabled =
|
||||
curAgentState === AgentState.LOADING ||
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
|
||||
return (
|
||||
<div data-testid="interactive-chat-box">
|
||||
<CustomChatInput
|
||||
disabled={isDisabled}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
onFilesPaste={handleUpload}
|
||||
conversationStatus={conversation?.status || null}
|
||||
/>
|
||||
<div className="mt-4">
|
||||
<GitControlBar
|
||||
onSuggestionsClick={handleSuggestionsClick}
|
||||
isWaitingForUserInput={isWaitingForUserInput}
|
||||
hasSubstantiveAgentActions={hasSubstantiveAgentActions}
|
||||
optimisticUserMessage={optimisticUserMessage}
|
||||
<div
|
||||
data-testid="interactive-chat-box"
|
||||
className="flex flex-col gap-[10px]"
|
||||
>
|
||||
{images.length > 0 && (
|
||||
<ImageCarousel
|
||||
size="small"
|
||||
images={images.map((image) => URL.createObjectURL(image))}
|
||||
onRemove={handleRemoveImage}
|
||||
/>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<FileList
|
||||
files={files.map((f) => f.name)}
|
||||
onRemove={handleRemoveFile}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-end gap-1",
|
||||
"bg-tertiary border border-neutral-600 rounded-lg px-2",
|
||||
"transition-colors duration-200",
|
||||
"hover:border-neutral-500 focus-within:border-neutral-500",
|
||||
)}
|
||||
>
|
||||
<UploadImageInput onUpload={handleUpload} />
|
||||
<ChatInput
|
||||
disabled={isDisabled}
|
||||
button={mode}
|
||||
onChange={onChange}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={onStop}
|
||||
value={value}
|
||||
onFilesPaste={handleUpload}
|
||||
className="py-[10px]"
|
||||
buttonClassName="py-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import CloseIcon from "#/icons/u-close.svg?react";
|
||||
import { cn, isMobileDevice } from "#/utils/utils";
|
||||
|
||||
interface RemoveFileButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function RemoveFileButton({ onClick }: RemoveFileButtonProps) {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-4 h-4 rounded-full items-center justify-center bg-[#25272D] hover:bg-[#A1A1A1] cursor-pointer absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200",
|
||||
isMobile && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<CloseIcon width={10} height={10} color="#ffffff" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user