mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
79 Commits
0.48.0
...
feature/or
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6e91d1021 | ||
|
|
c7e9f99759 | ||
|
|
9e72b69cf8 | ||
|
|
da1f3a5a7b | ||
|
|
5c27a452ac | ||
|
|
8cb1c738ff | ||
|
|
cf276b2e96 | ||
|
|
1f416f616c | ||
|
|
52775acd4d | ||
|
|
be0596abd6 | ||
|
|
e77957aa92 | ||
|
|
d04c4c493e | ||
|
|
5cb534217a | ||
|
|
9331f5e8a7 | ||
|
|
8d16567428 | ||
|
|
acc69b74c5 | ||
|
|
28d174a7ce | ||
|
|
cff5697456 | ||
|
|
794eedf503 | ||
|
|
a6ffb2f799 | ||
|
|
3be3779f68 | ||
|
|
222f5fdd51 | ||
|
|
2066f90654 | ||
|
|
9ee2f976a1 | ||
|
|
be62df5277 | ||
|
|
4baf2a64c1 | ||
|
|
2a833325e1 | ||
|
|
aa2cacab44 | ||
|
|
ea07570f62 | ||
|
|
3f5a5005a2 | ||
|
|
7acee9e5da | ||
|
|
37cbeb735f | ||
|
|
c6c6c202f6 | ||
|
|
517a72fd0d | ||
|
|
7cfecb6e52 | ||
|
|
8fe2e006ee | ||
|
|
6d62c341eb | ||
|
|
229f35093d | ||
|
|
21a5e3eed5 | ||
|
|
97e3310dd5 | ||
|
|
2053e72474 | ||
|
|
300f20368e | ||
|
|
0bed046fcc | ||
|
|
0bf0dc9316 | ||
|
|
0e8d9a8bb4 | ||
|
|
9280bc34ad | ||
|
|
b132348d22 | ||
|
|
1be77faf94 | ||
|
|
a6301075ec | ||
|
|
b98615bc1c | ||
|
|
29fdc701a3 | ||
|
|
8bc9207c24 | ||
|
|
96008736a4 | ||
|
|
38d5db0547 | ||
|
|
8af1f1cac9 | ||
|
|
ef502ccba8 | ||
|
|
ece556c047 | ||
|
|
55a09785ce | ||
|
|
2990c21d97 | ||
|
|
14c8ea93c9 | ||
|
|
764077ef3d | ||
|
|
63ead2a638 | ||
|
|
be0049c76e | ||
|
|
bafd1596dd | ||
|
|
ce58ccab8a | ||
|
|
b3c8b7c089 | ||
|
|
ac2947b7ff | ||
|
|
91cd647f20 | ||
|
|
c521fb7a8f | ||
|
|
f049411631 | ||
|
|
606ec59b33 | ||
|
|
d2fc5679ad | ||
|
|
7bfa05d38a | ||
|
|
12a95fb548 | ||
|
|
ae03c4eb80 | ||
|
|
8e486dfd6b | ||
|
|
48ee5659c9 | ||
|
|
b7613d7529 | ||
|
|
e05e627957 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -3,6 +3,7 @@
|
||||
|
||||
# Frontend code owners
|
||||
/frontend/ @rbren @amanape
|
||||
/openhands-ui/ @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
8
.github/workflows/fe-unit-tests.yml
vendored
8
.github/workflows/fe-unit-tests.yml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- 'frontend/**'
|
||||
- '.github/workflows/fe-unit-tests.yml'
|
||||
- "frontend/**"
|
||||
- ".github/workflows/fe-unit-tests.yml"
|
||||
|
||||
# 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:
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [20, 22]
|
||||
node-version: 22
|
||||
fail-fast: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
run: npm ci
|
||||
- name: Run TypeScript compilation
|
||||
working-directory: ./frontend
|
||||
run: npm run make-i18n && tsc
|
||||
run: npm run build
|
||||
- name: Run tests and collect coverage
|
||||
working-directory: ./frontend
|
||||
run: npm run test:coverage
|
||||
|
||||
2
.github/workflows/ghcr-build.yml
vendored
2
.github/workflows/ghcr-build.yml
vendored
@@ -54,6 +54,7 @@ jobs:
|
||||
ghcr_build_app:
|
||||
name: Build App Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -103,6 +104,7 @@ jobs:
|
||||
ghcr_build_runtime:
|
||||
name: Build Image
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
6
.github/workflows/lint-fix.yml
vendored
6
.github/workflows/lint-fix.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Fix python lint issues
|
||||
|
||||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
@@ -7,7 +7,7 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
# If triggered by a PR, it will be in the same group. However, each commit on main will be in its own unique group
|
||||
@@ -22,10 +22,10 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Node.js 20
|
||||
- name: Install Node.js 22
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: 'pip'
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==3.7.0
|
||||
- name: Run pre-commit hooks
|
||||
|
||||
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
156
.github/workflows/vscode-extension-build.yml
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
# Workflow that validates the VSCode extension builds correctly
|
||||
name: VSCode Extension CI
|
||||
|
||||
# * Always run on "main"
|
||||
# * Run on PRs that have changes in the VSCode extension folder or this workflow
|
||||
# * Run on tags that start with "ext-v"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'ext-v*'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'openhands/integrations/vscode/**'
|
||||
- 'build_vscode.py'
|
||||
- '.github/workflows/vscode-extension-build.yml'
|
||||
|
||||
# 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:
|
||||
# Validate VSCode extension builds correctly
|
||||
validate-vscode-extension:
|
||||
name: Validate VSCode Extension Build
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: useblacksmith/setup-node@v5
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install VSCode extension dependencies
|
||||
working-directory: ./openhands/integrations/vscode
|
||||
run: npm ci
|
||||
|
||||
- name: Build VSCode extension via build_vscode.py
|
||||
run: python build_vscode.py
|
||||
env:
|
||||
# Ensure we don't skip the build
|
||||
SKIP_VSCODE_BUILD: ""
|
||||
|
||||
- name: Validate .vsix file
|
||||
run: |
|
||||
# Verify the .vsix was created and is valid
|
||||
if [ -f "openhands/integrations/vscode/openhands-vscode-0.0.1.vsix" ]; then
|
||||
echo "✅ VSCode extension built successfully"
|
||||
ls -la openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
# Basic validation that the .vsix is a valid zip file
|
||||
echo "🔍 Validating .vsix structure..."
|
||||
file openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
unzip -t openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
|
||||
echo "✅ VSCode extension validation passed"
|
||||
else
|
||||
echo "❌ VSCode extension build failed - .vsix not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Upload VSCode extension artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: openhands/integrations/vscode/openhands-vscode-0.0.1.vsix
|
||||
retention-days: 7
|
||||
|
||||
- name: Comment on PR with artifact link
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Get file size for display
|
||||
const vsixPath = 'openhands/integrations/vscode/openhands-vscode-0.0.1.vsix';
|
||||
const stats = fs.statSync(vsixPath);
|
||||
const fileSizeKB = Math.round(stats.size / 1024);
|
||||
|
||||
const comment = `## 🔧 VSCode Extension Built Successfully!
|
||||
|
||||
The VSCode extension has been built and is ready for testing.
|
||||
|
||||
**📦 Download**: [openhands-vscode-0.0.1.vsix](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}) (${fileSizeKB} KB)
|
||||
|
||||
**🚀 To install**:
|
||||
1. Download the artifact from the workflow run above
|
||||
2. In VSCode: \`Ctrl+Shift+P\` → "Extensions: Install from VSIX..."
|
||||
3. Select the downloaded \`.vsix\` file
|
||||
|
||||
**✅ Tested with**: Node.js 22
|
||||
**🔍 Validation**: File structure and integrity verified
|
||||
|
||||
---
|
||||
*Built from commit ${{ github.sha }}*`;
|
||||
|
||||
// Check if we already commented on this PR and delete it
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('VSCode Extension Built Successfully')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
await github.rest.issues.deleteComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: botComment.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Create a new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: comment
|
||||
});
|
||||
|
||||
release:
|
||||
name: Create GitHub Release
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
needs: validate-vscode-extension
|
||||
if: startsWith(github.ref, 'refs/tags/ext-v')
|
||||
|
||||
steps:
|
||||
- name: Download .vsix artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: vscode-extension
|
||||
path: ./
|
||||
|
||||
- name: Create Release
|
||||
uses: ncipollo/release-action@v1.16.0
|
||||
with:
|
||||
artifacts: "*.vsix"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
draft: true
|
||||
allowUpdates: true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -182,6 +182,8 @@ cython_debug/
|
||||
.roo/rules
|
||||
.cline/rules
|
||||
.windsurf/rules
|
||||
.repomix
|
||||
repomix-output.txt
|
||||
|
||||
# evaluation
|
||||
evaluation/evaluation_outputs
|
||||
|
||||
@@ -15,10 +15,13 @@ make build && make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.
|
||||
|
||||
IMPORTANT: Before making any changes to the codebase, ALWAYS run `make install-pre-commit-hooks` to ensure pre-commit hooks are properly installed.
|
||||
|
||||
|
||||
|
||||
Before pushing any changes, you MUST ensure that any lint errors or simple test errors have been fixed.
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
|
||||
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
|
||||
|
||||
@@ -60,6 +63,22 @@ Frontend:
|
||||
- Mutation hooks should follow the pattern use[Action] (e.g., `useDeleteConversation`)
|
||||
- Architecture rule: UI components → TanStack Query hooks → Data Access Layer (`frontend/src/api`) → API endpoints
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
- Setup: Run `npm install` in the extension directory
|
||||
- Linting:
|
||||
- Run linting with fixes: `npm run lint:fix`
|
||||
- Check only: `npm run lint`
|
||||
- Type checking: `npm run typecheck`
|
||||
- Building:
|
||||
- Compile TypeScript: `npm run compile`
|
||||
- Package extension: `npm run package-vsix`
|
||||
- Testing:
|
||||
- Run tests: `npm run test`
|
||||
- Development Best Practices:
|
||||
- Use `vscode.window.createOutputChannel()` for debug logging instead of `showErrorMessage()` popups
|
||||
- Pre-commit process runs both frontend and backend checks when committing extension changes
|
||||
|
||||
## Template for Github Pull Request
|
||||
|
||||
If you are starting a pull request (PR), please follow the template in `.github/pull_request_template.md`.
|
||||
|
||||
@@ -31,7 +31,7 @@ We're always looking to improve the look and feel of the application. If you've
|
||||
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
|
||||
|
||||
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
|
||||
of the application, please open an issue first, or better, join the #frontend channel in our Slack
|
||||
of the application, please open an issue first, or better, join the #eng-ui-ux channel in our Slack
|
||||
to gather consensus from our design team first.
|
||||
|
||||
#### Improving the agent
|
||||
|
||||
114
build_vscode.py
Normal file
114
build_vscode.py
Normal file
@@ -0,0 +1,114 @@
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
|
||||
# This script is intended to be run by Poetry during the build process.
|
||||
|
||||
# Define the expected name of the .vsix file based on the extension's package.json
|
||||
# This should match the name and version in openhands-vscode/package.json
|
||||
EXTENSION_NAME = 'openhands-vscode'
|
||||
EXTENSION_VERSION = '0.0.1'
|
||||
VSIX_FILENAME = f'{EXTENSION_NAME}-{EXTENSION_VERSION}.vsix'
|
||||
|
||||
# Paths
|
||||
ROOT_DIR = pathlib.Path(__file__).parent.resolve()
|
||||
VSCODE_EXTENSION_DIR = ROOT_DIR / 'openhands' / 'integrations' / 'vscode'
|
||||
|
||||
|
||||
def check_node_version():
|
||||
"""Check if Node.js version is sufficient for building the extension."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['node', '--version'], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Extract major version number (e.g., "v12.22.9" -> 12)
|
||||
major_version = int(version_str.lstrip('v').split('.')[0])
|
||||
return major_version >= 18 # Align with frontend actual usage (18.20.1)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def build_vscode_extension():
|
||||
"""Builds the VS Code extension."""
|
||||
vsix_path = VSCODE_EXTENSION_DIR / VSIX_FILENAME
|
||||
|
||||
# Check if VSCode extension build is disabled via environment variable
|
||||
if os.environ.get('SKIP_VSCODE_BUILD', '').lower() in ('1', 'true', 'yes'):
|
||||
print('--- Skipping VS Code extension build (SKIP_VSCODE_BUILD is set) ---')
|
||||
if vsix_path.exists():
|
||||
print(f'--- Using existing VS Code extension: {vsix_path} ---')
|
||||
else:
|
||||
print('--- No pre-built VS Code extension found ---')
|
||||
return
|
||||
|
||||
# Check Node.js version - if insufficient, use pre-built extension as fallback
|
||||
if not check_node_version():
|
||||
print('--- Warning: Node.js version < 18 detected or Node.js not found ---')
|
||||
print('--- Skipping VS Code extension build (requires Node.js >= 18) ---')
|
||||
print('--- Using pre-built extension if available ---')
|
||||
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
else:
|
||||
print(f'--- Using pre-built VS Code extension: {vsix_path} ---')
|
||||
return
|
||||
|
||||
print(f'--- Building VS Code extension in {VSCODE_EXTENSION_DIR} ---')
|
||||
|
||||
try:
|
||||
# Ensure npm dependencies are installed
|
||||
print('--- Running npm install for VS Code extension ---')
|
||||
subprocess.run(
|
||||
['npm', 'install'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Package the extension
|
||||
print(f'--- Packaging VS Code extension ({VSIX_FILENAME}) ---')
|
||||
subprocess.run(
|
||||
['npm', 'run', 'package-vsix'],
|
||||
cwd=VSCODE_EXTENSION_DIR,
|
||||
check=True,
|
||||
shell=os.name == 'nt',
|
||||
)
|
||||
|
||||
# Verify the generated .vsix file exists
|
||||
if not vsix_path.exists():
|
||||
raise FileNotFoundError(
|
||||
f'VS Code extension package not found after build: {vsix_path}'
|
||||
)
|
||||
|
||||
print(f'--- VS Code extension built successfully: {vsix_path} ---')
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f'--- Warning: Failed to build VS Code extension: {e} ---')
|
||||
print('--- Continuing without building extension ---')
|
||||
if not vsix_path.exists():
|
||||
print('--- Warning: No pre-built VS Code extension found ---')
|
||||
print('--- VS Code extension will not be available ---')
|
||||
|
||||
|
||||
def build(setup_kwargs):
|
||||
"""
|
||||
This function is called by Poetry during the build process.
|
||||
`setup_kwargs` is a dictionary that will be passed to `setuptools.setup()`.
|
||||
"""
|
||||
print('--- Running custom Poetry build script (build_vscode.py) ---')
|
||||
|
||||
# Build the VS Code extension and place the .vsix file
|
||||
build_vscode_extension()
|
||||
|
||||
# Poetry will handle including files based on pyproject.toml `include` patterns.
|
||||
# Ensure openhands/integrations/vscode/*.vsix is included there.
|
||||
|
||||
print('--- Custom Poetry build script (build_vscode.py) finished ---')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running build_vscode.py directly for testing VS Code extension packaging...')
|
||||
build_vscode_extension()
|
||||
print('Direct execution of build_vscode.py finished.')
|
||||
@@ -18,9 +18,6 @@
|
||||
# Cache directory path
|
||||
#cache_dir = "/tmp/cache"
|
||||
|
||||
# Reasoning effort for o1 models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Debugging enabled
|
||||
#debug = false
|
||||
|
||||
@@ -49,6 +46,9 @@
|
||||
# Maximum file size for uploads, in megabytes
|
||||
#file_uploads_max_file_size_mb = 0
|
||||
|
||||
# Enable the browser environment
|
||||
#enable_browser = true
|
||||
|
||||
# Maximum budget per task, 0.0 means no limit
|
||||
#max_budget_per_task = 0.0
|
||||
|
||||
@@ -116,6 +116,9 @@ api_key = ""
|
||||
# API version
|
||||
#api_version = ""
|
||||
|
||||
# Reasoning effort for OpenAI o-series models (low, medium, high, or not set)
|
||||
#reasoning_effort = "medium"
|
||||
|
||||
# Cost per input token
|
||||
#input_cost_per_token = 0.0
|
||||
|
||||
@@ -226,6 +229,7 @@ model = "gpt-4o"
|
||||
[agent]
|
||||
|
||||
# Whether the browsing tool is enabled
|
||||
# Note: when this is set to true, enable_browser in the core config must also be true
|
||||
enable_browsing = true
|
||||
|
||||
# Whether the LLM draft editor is enabled
|
||||
|
||||
@@ -8,6 +8,12 @@ description: This page outlines all available configuration options for OpenHand
|
||||
In GUI Mode, any settings applied through the Settings UI will take precedence.
|
||||
</Note>
|
||||
|
||||
## Location of the `config.toml` File
|
||||
|
||||
When running OpenHands in CLI, headless, or development mode, you can use a project-specific `config.toml` file for configuration, which must be
|
||||
located in the same directory from which the command is run. Alternatively, you may use the `--config-file` option to
|
||||
specify a different path to the `config.toml` file.
|
||||
|
||||
## Core Configuration
|
||||
|
||||
The core configuration options are defined in the `[core]` section of the `config.toml` file.
|
||||
|
||||
@@ -33,6 +33,45 @@ pip install openhands-ai
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
<Accordion title="Create shell aliases for easy access across environments">
|
||||
|
||||
Add the following to your shell configuration file (`.bashrc`, `.zshrc`, etc.):
|
||||
|
||||
```bash
|
||||
# Add OpenHands aliases
|
||||
alias openhands="uvx --python 3.12 --from openhands-ai openhands"
|
||||
alias oh="uvx --python 3.12 --from openhands-ai openhands"
|
||||
```
|
||||
|
||||
After adding these lines, reload your shell configuration with `source ~/.bashrc` or `source ~/.zshrc` (depending on your shell).
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Install OpenHands in home directory without global installation">
|
||||
|
||||
You can install OpenHands in a virtual environment in your home directory using `uv`:
|
||||
|
||||
```bash
|
||||
# Create a virtual environment in your home directory
|
||||
cd ~
|
||||
uv venv .openhands-venv --python 3.12
|
||||
|
||||
# Install OpenHands in the virtual environment
|
||||
uv pip install -t ~/.openhands-venv/lib/python3.12/site-packages openhands-ai
|
||||
|
||||
# Add the bin directory to your PATH in your shell configuration file
|
||||
echo 'export PATH="$PATH:$HOME/.openhands-venv/bin"' >> ~/.bashrc # or ~/.zshrc
|
||||
|
||||
# Reload your shell configuration
|
||||
source ~/.bashrc # or source ~/.zshrc
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
2. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
openhands
|
||||
|
||||
@@ -122,17 +122,15 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
#### BitBucket Setup (Coming soon ...)
|
||||
#### BitBucket Setup
|
||||
<AccordionGroup>
|
||||
<Accordion title="Setting Up a BitBucket Password">
|
||||
1. **Generate an App Password**:
|
||||
- On BitBucket, go to Personal Settings > App Password.
|
||||
- Create a new password with the following scopes:
|
||||
- `repository: read`
|
||||
- `account`: `read`
|
||||
- `repository: write`
|
||||
- `pull requests: read`
|
||||
- `pull requests: write`
|
||||
- `issues: read`
|
||||
- `issues: write`
|
||||
- App passwords are non-expiring token. OpenHands will migrate to using API tokens in the future.
|
||||
2. **Enter Token in OpenHands**:
|
||||
|
||||
@@ -175,6 +175,27 @@ vllm serve mistralai/Devstral-Small-2505 \
|
||||
--enable-prefix-caching
|
||||
```
|
||||
|
||||
If you are interested in further improved inference speed, you can also try Snowflake's version
|
||||
of vLLM, [ArcticInference](https://www.snowflake.com/en/engineering-blog/fast-speculative-decoding-vllm-arctic/),
|
||||
which can achieve up to 2x speedup in some cases.
|
||||
|
||||
1. Install the Arctic Inference library that automatically patches vLLM:
|
||||
|
||||
```bash
|
||||
pip install git+https://github.com/snowflakedb/ArcticInference.git
|
||||
```
|
||||
|
||||
2. Run the launch command with speculative decoding enabled:
|
||||
|
||||
```bash
|
||||
vllm serve mistralai/Devstral-Small-2505 \
|
||||
--host 0.0.0.0 --port 8000 \
|
||||
--api-key mykey \
|
||||
--tensor-parallel-size 2 \
|
||||
--served-model-name Devstral-Small-2505 \
|
||||
--speculative-config '{"method": "suffix"}'
|
||||
```
|
||||
|
||||
### Run OpenHands (Alternative Backends)
|
||||
|
||||
#### Using Docker
|
||||
|
||||
@@ -24,3 +24,12 @@ General microagent file example for organization `Great-Co` located inside the `
|
||||
```
|
||||
|
||||
For GitLab organizations, the same microagent would be located inside the `openhands-config` repository.
|
||||
|
||||
## User Microagents When Running Openhands on Your Own
|
||||
|
||||
<Note>
|
||||
This works with CLI, headless and development modes. It does not work out of the box when running OpenHands using the docker command.
|
||||
</Note>
|
||||
|
||||
When running OpenHands on your own, you can place microagents in the `~/.openhands/microagents` folder on your local
|
||||
system and OpenHands will always load it for all your conversations.
|
||||
|
||||
@@ -38,6 +38,21 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
|
||||
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
|
||||
OpenHands.
|
||||
|
||||
### Internal Server Error. Ports are not available
|
||||
|
||||
**Description**
|
||||
|
||||
When running on Windows, the error `Internal Server Error ("ports are not available: exposing port TCP
|
||||
...: bind: An attempt was made to access a socket in a
|
||||
way forbidden by its access permissions.")` is encountered.
|
||||
|
||||
**Resolution**
|
||||
|
||||
* Run the following command in PowerShell, as Administrator to reset the NAT service and release the ports:
|
||||
```
|
||||
Restart-Service -Name "winnat"
|
||||
```
|
||||
|
||||
### Unable to access VS Code tab via local IP
|
||||
|
||||
**Description**
|
||||
|
||||
@@ -109,9 +109,7 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
|
||||
template_name = 'swt.j2'
|
||||
elif mode == 'swe':
|
||||
if 'claude' in llm_model:
|
||||
template_name = 'swe_claude.j2'
|
||||
elif 'gemini' in llm_model:
|
||||
template_name = 'swe_gemini.j2'
|
||||
template_name = 'swe_default.j2'
|
||||
elif 'gpt-4.1' in llm_model:
|
||||
template_name = 'swe_gpt4.j2'
|
||||
else:
|
||||
|
||||
@@ -13,8 +13,9 @@
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:@tanstack/query/recommended",
|
||||
],
|
||||
"plugins": ["prettier", "unused-imports"],
|
||||
"plugins": ["prettier", "unused-imports", "i18next"],
|
||||
"rules": {
|
||||
"i18next/no-literal-string": "error",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"prettier/prettier": ["error"],
|
||||
// Resolves https://stackoverflow.com/questions/59265981/typescript-eslint-missing-file-extension-ts-import-extensions/59268871#59268871
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Run frontend checks
|
||||
echo "Running frontend checks..."
|
||||
cd frontend
|
||||
npm run check-unlocalized-strings
|
||||
npm run lint
|
||||
npm run check-translation-completeness
|
||||
npx lint-staged
|
||||
|
||||
|
||||
@@ -10,9 +10,7 @@ describe("ChatMessage", () => {
|
||||
expect(screen.getByText("Hello, World!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.todo("should render an assistant message");
|
||||
|
||||
it.skip("should support code syntax highlighting", () => {
|
||||
it("should support code syntax highlighting", () => {
|
||||
const code = "```js\nconsole.log('Hello, World!')\n```";
|
||||
render(<ChatMessage type="user" message={code} />);
|
||||
|
||||
@@ -46,8 +44,6 @@ describe("ChatMessage", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should display an error toast if copying content to clipboard fails", async () => {});
|
||||
|
||||
it("should render a component passed as a prop", () => {
|
||||
function Component() {
|
||||
return <div data-testid="custom-component">Custom Component</div>;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { LaunchMicroagentModal } from "#/components/features/chat/microagent/launch-microagent-modal";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
vi.mock("react-router", async () => ({
|
||||
useParams: vi.fn().mockReturnValue({
|
||||
conversationId: "123",
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useHandleRuntimeActive hook
|
||||
vi.mock("#/hooks/use-handle-runtime-active", () => ({
|
||||
useHandleRuntimeActive: vi.fn().mockReturnValue({ runtimeActive: true }),
|
||||
}));
|
||||
|
||||
// Mock the useMicroagentPrompt hook
|
||||
vi.mock("#/hooks/query/use-microagent-prompt", () => ({
|
||||
useMicroagentPrompt: vi.fn().mockReturnValue({
|
||||
data: "Generated prompt",
|
||||
isLoading: false
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useGetMicroagents hook
|
||||
vi.mock("#/hooks/query/use-get-microagents", () => ({
|
||||
useGetMicroagents: vi.fn().mockReturnValue({
|
||||
data: ["file1", "file2"]
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the useTranslation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
[I18nKey.MICROAGENT$ADD_TO_MICROAGENT]: "Add to Microagent",
|
||||
[I18nKey.MICROAGENT$WHAT_TO_REMEMBER]: "What would you like your microagent to remember?",
|
||||
[I18nKey.MICROAGENT$WHERE_TO_PUT]: "Where should we put it?",
|
||||
[I18nKey.MICROAGENT$ADD_TRIGGERS]: "Add triggers for the microagent",
|
||||
[I18nKey.MICROAGENT$DESCRIBE_WHAT_TO_ADD]: "Describe what you want to add to the Microagent...",
|
||||
[I18nKey.MICROAGENT$SELECT_FILE_OR_CUSTOM]: "Select a microagent file or enter a custom value",
|
||||
[I18nKey.MICROAGENT$TYPE_TRIGGER_SPACE]: "Type a trigger and press Space to add it",
|
||||
[I18nKey.MICROAGENT$LOADING_PROMPT]: "Loading prompt...",
|
||||
[I18nKey.MICROAGENT$CANCEL]: "Cancel",
|
||||
[I18nKey.MICROAGENT$LAUNCH]: "Launch"
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
i18n: {
|
||||
changeLanguage: vi.fn(),
|
||||
},
|
||||
}),
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
|
||||
}));
|
||||
|
||||
describe("LaunchMicroagentModal", () => {
|
||||
const onCloseMock = vi.fn();
|
||||
const onLaunchMock = vi.fn();
|
||||
const eventId = 12;
|
||||
const conversationId = "123";
|
||||
|
||||
const renderMicroagentModal = (
|
||||
{ isLoading }: { isLoading: boolean } = { isLoading: false },
|
||||
) =>
|
||||
render(
|
||||
<LaunchMicroagentModal
|
||||
onClose={onCloseMock}
|
||||
onLaunch={onLaunchMock}
|
||||
eventId={eventId}
|
||||
selectedRepo="some-repo"
|
||||
isLoading={isLoading}
|
||||
/>,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the launch microagent modal", () => {
|
||||
renderMicroagentModal();
|
||||
expect(screen.getByTestId("launch-microagent-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the form fields", () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// inputs
|
||||
screen.getByTestId("query-input");
|
||||
screen.getByTestId("target-input");
|
||||
screen.getByTestId("trigger-input");
|
||||
|
||||
// action buttons
|
||||
screen.getByRole("button", { name: "Launch" });
|
||||
screen.getByRole("button", { name: "Cancel" });
|
||||
});
|
||||
|
||||
it("should call onClose when pressing the cancel button", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: "Cancel" });
|
||||
await userEvent.click(cancelButton);
|
||||
expect(onCloseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display the prompt from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const descriptionInput = screen.getByTestId("query-input");
|
||||
expect(descriptionInput).toHaveValue("Generated prompt");
|
||||
});
|
||||
|
||||
it("should display the list of microagent files from the hook", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
// Since we're mocking the hook, we just need to verify the UI shows the data
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
expect(targetInput).toHaveValue("");
|
||||
|
||||
await userEvent.click(targetInput);
|
||||
|
||||
expect(screen.getByText("file1")).toBeInTheDocument();
|
||||
expect(screen.getByText("file2")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
expect(targetInput).toHaveValue("file1");
|
||||
});
|
||||
|
||||
it("should call onLaunch with the form data", async () => {
|
||||
renderMicroagentModal();
|
||||
|
||||
const triggerInput = screen.getByTestId("trigger-input");
|
||||
await userEvent.type(triggerInput, "trigger1 ");
|
||||
await userEvent.type(triggerInput, "trigger2 ");
|
||||
|
||||
const targetInput = screen.getByTestId("target-input");
|
||||
await userEvent.click(targetInput);
|
||||
await userEvent.click(screen.getByText("file1"));
|
||||
|
||||
const launchButton = await screen.findByRole("button", { name: "Launch" });
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(onLaunchMock).toHaveBeenCalledWith("Generated prompt", "file1", [
|
||||
"trigger1",
|
||||
"trigger2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should disable the launch button if isLoading is true", async () => {
|
||||
renderMicroagentModal({ isLoading: true });
|
||||
|
||||
const launchButton = screen.getByRole("button", { name: "Launch" });
|
||||
expect(launchButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
107
frontend/__tests__/components/features/chat/messages.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Messages } from "#/components/features/chat/messages";
|
||||
import {
|
||||
AssistantMessageAction,
|
||||
OpenHandsAction,
|
||||
UserMessageAction,
|
||||
} from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { Conversation } from "#/api/open-hands.types";
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ conversationId: "123" }),
|
||||
}));
|
||||
|
||||
let queryClient: QueryClient;
|
||||
|
||||
const renderMessages = ({
|
||||
messages,
|
||||
}: {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
}) => {
|
||||
const { rerender, ...rest } = render(
|
||||
<Messages messages={messages} isAwaitingUserConfirmation={false} />,
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient!}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
const rerenderMessages = (
|
||||
newMessages: (OpenHandsAction | OpenHandsObservation)[],
|
||||
) => {
|
||||
rerender(
|
||||
<Messages messages={newMessages} isAwaitingUserConfirmation={false} />,
|
||||
);
|
||||
};
|
||||
|
||||
return { ...rest, rerender: rerenderMessages };
|
||||
};
|
||||
|
||||
describe("Messages", () => {
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient();
|
||||
});
|
||||
|
||||
const assistantMessage: AssistantMessageAction = {
|
||||
id: 0,
|
||||
action: "message",
|
||||
source: "agent",
|
||||
message: "Hello, Assistant!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: {
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
thought: "",
|
||||
wait_for_response: false,
|
||||
},
|
||||
};
|
||||
|
||||
const userMessage: UserMessageAction = {
|
||||
id: 1,
|
||||
action: "message",
|
||||
source: "user",
|
||||
message: "Hello, User!",
|
||||
timestamp: new Date().toISOString(),
|
||||
args: { content: "Hello, User!", image_urls: [], file_urls: [] },
|
||||
};
|
||||
|
||||
it("should render", () => {
|
||||
renderMessages({ messages: [userMessage, assistantMessage] });
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a launch to microagent action button on chat messages only if it is a user message", () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
const mockConversation: Conversation = {
|
||||
conversation_id: "123",
|
||||
title: "Test Conversation",
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
created_at: new Date().toISOString(),
|
||||
last_updated_at: new Date().toISOString(),
|
||||
selected_branch: null,
|
||||
selected_repository: null,
|
||||
git_provider: "github",
|
||||
session_api_key: null,
|
||||
url: null,
|
||||
};
|
||||
|
||||
getConversationSpy.mockResolvedValue(mockConversation);
|
||||
|
||||
renderMessages({
|
||||
messages: [userMessage, assistantMessage],
|
||||
});
|
||||
|
||||
expect(screen.getByText("Hello, User!")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hello, Assistant!")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -27,9 +27,9 @@ vi.mock("react-i18next", async () => {
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"CONVERSATION$CREATED": "Created",
|
||||
"CONVERSATION$AGO": "ago",
|
||||
"CONVERSATION$UPDATED": "Updated"
|
||||
CONVERSATION$CREATED: "Created",
|
||||
CONVERSATION$AGO: "ago",
|
||||
CONVERSATION$UPDATED: "Updated",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -82,7 +82,9 @@ describe("ConversationCard", () => {
|
||||
expect(card).toHaveTextContent("ago");
|
||||
|
||||
// Use a regex to match the time part since it might have whitespace
|
||||
const timeRegex = new RegExp(formatTimeDelta(new Date("2021-10-01T12:00:00Z")));
|
||||
const timeRegex = new RegExp(
|
||||
formatTimeDelta(new Date("2021-10-01T12:00:00Z")),
|
||||
);
|
||||
expect(card).toHaveTextContent(timeRegex);
|
||||
});
|
||||
|
||||
@@ -108,7 +110,11 @@ describe("ConversationCard", () => {
|
||||
onChangeTitle={onChangeTitle}
|
||||
isActive
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
selectedRepository={{
|
||||
selected_repository: "org/selectedRepository",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
}}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
@@ -173,7 +179,11 @@ describe("ConversationCard", () => {
|
||||
isActive
|
||||
onChangeTitle={onChangeTitle}
|
||||
title="Conversation 1"
|
||||
selectedRepository="org/selectedRepository"
|
||||
selectedRepository={{
|
||||
selected_repository: "org/selectedRepository",
|
||||
selected_branch: "main",
|
||||
git_provider: "github",
|
||||
}}
|
||||
lastUpdatedAt="2021-10-01T12:00:00Z"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -17,12 +17,12 @@ vi.mock("react-i18next", async () => {
|
||||
t: (key: string) => {
|
||||
// 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"
|
||||
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;
|
||||
},
|
||||
@@ -69,7 +69,6 @@ describe("HomeHeader", () => {
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
|
||||
@@ -119,18 +119,48 @@ describe("RepoConnector", () => {
|
||||
expect(launchButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("should render the 'add git(hub|lab) repos' links if saas mode", 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
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "some-token",
|
||||
gitlab: null,
|
||||
},
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
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
|
||||
getConfiSpy.mockResolvedValue({
|
||||
APP_MODE: "saas",
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "some-token",
|
||||
github: null,
|
||||
},
|
||||
});
|
||||
|
||||
renderRepoConnector();
|
||||
|
||||
expect(screen.queryByText("HOME$ADD_GITHUB_REPOS")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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
|
||||
@@ -176,9 +206,8 @@ describe("RepoConnector", () => {
|
||||
"rbren/polaris",
|
||||
"github",
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
"main",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -66,6 +66,11 @@ vi.mock("#/hooks/use-debounce", () => ({
|
||||
useDebounce: (value: string) => value,
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async (importActual) => ({
|
||||
...(await importActual()),
|
||||
useNavigate: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
const renderForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />, {
|
||||
@@ -252,8 +257,6 @@ describe("RepositorySelectionForm", () => {
|
||||
expect(searchedRepo).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(searchedRepo);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(
|
||||
MOCK_SEARCH_REPOS[0].full_name,
|
||||
);
|
||||
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,9 +88,14 @@ describe("TaskCard", () => {
|
||||
MOCK_RESPOSITORIES[0].full_name,
|
||||
MOCK_RESPOSITORIES[0].git_provider,
|
||||
undefined,
|
||||
[],
|
||||
{
|
||||
git_provider: "github",
|
||||
issue_number: 123,
|
||||
repo: "repo1",
|
||||
task_type: "MERGE_CONFLICTS",
|
||||
title: "Task 1",
|
||||
},
|
||||
undefined,
|
||||
MOCK_TASK_1,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { MicroagentsModal } from "#/components/features/conversation-panel/microagents-modal";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
vi.mock("react-redux", async () => {
|
||||
const actual = await vi.importActual("react-redux");
|
||||
return {
|
||||
...actual,
|
||||
useDispatch: () => vi.fn(),
|
||||
useSelector: () => ({
|
||||
agent: {
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
},
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("MicroagentsModal - Refresh Button", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const conversationId = "test-conversation-id";
|
||||
|
||||
const defaultProps = {
|
||||
onClose: mockOnClose,
|
||||
conversationId,
|
||||
};
|
||||
|
||||
const mockMicroagents = [
|
||||
{
|
||||
name: "Test Agent 1",
|
||||
type: "repo" as const,
|
||||
triggers: ["test", "example"],
|
||||
content: "This is test content for agent 1",
|
||||
},
|
||||
{
|
||||
name: "Test Agent 2",
|
||||
type: "knowledge" as const,
|
||||
triggers: ["help", "support"],
|
||||
content: "This is test content for agent 2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default mock for getUserConversations
|
||||
vi.spyOn(OpenHands, "getMicroagents").mockResolvedValue({
|
||||
microagents: mockMicroagents,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("Refresh Button Rendering", () => {
|
||||
it("should render the refresh button with correct text and test ID", () => {
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-microagents");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
expect(refreshButton).toHaveTextContent("BUTTON$REFRESH");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Button Functionality", () => {
|
||||
it("should call refetch when refresh button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWithProviders(<MicroagentsModal {...defaultProps} />);
|
||||
|
||||
const refreshSpy = vi.spyOn(OpenHands, "getMicroagents");
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh-microagents");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
|
||||
describe("BadgeInput", () => {
|
||||
it("should render the values", () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["test", "test2"]} onChange={onChangeMock} />);
|
||||
|
||||
expect(screen.getByText("test")).toBeInTheDocument();
|
||||
expect(screen.getByText("test2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the input's as a badge on space", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "test");
|
||||
await userEvent.type(input, " ");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1", "test"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on backspace", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1", "badge2"]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, "{backspace}");
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith(["badge1"]);
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should remove the badge on click", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={["badge1"]} onChange={onChangeMock} />);
|
||||
|
||||
const removeButton = screen.getByTestId("remove-button");
|
||||
await userEvent.click(removeButton);
|
||||
|
||||
expect(onChangeMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("should not create empty badges", async () => {
|
||||
const onChangeMock = vi.fn();
|
||||
render(<BadgeInput value={[]} onChange={onChangeMock} />);
|
||||
|
||||
const input = screen.getByTestId("badge-input");
|
||||
expect(input).toHaveValue("");
|
||||
|
||||
await userEvent.type(input, " ");
|
||||
expect(onChangeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
140
frontend/__tests__/hooks/query/use-feedback-exists.test.tsx
Normal file
140
frontend/__tests__/hooks/query/use-feedback-exists.test.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useFeedbackExists } from "#/hooks/query/use-feedback-exists";
|
||||
|
||||
// Mock the useConfig hook
|
||||
vi.mock("#/hooks/query/use-config", () => ({
|
||||
useConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useConversationId hook
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({ conversationId: "test-conversation-id" }),
|
||||
}));
|
||||
|
||||
describe("useFeedbackExists", () => {
|
||||
let queryClient: QueryClient;
|
||||
const mockCheckFeedbackExists = vi.spyOn(OpenHands, "checkFeedbackExists");
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
mockCheckFeedbackExists.mockClear();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
it("should not call API when APP_MODE is not saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "oss" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call API when APP_MODE is saas", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
mockCheckFeedbackExists.mockResolvedValue({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for the query to complete
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was called
|
||||
expect(mockCheckFeedbackExists).toHaveBeenCalledWith(
|
||||
"test-conversation-id",
|
||||
123,
|
||||
);
|
||||
|
||||
// Verify that the data is returned
|
||||
expect(result.current.data).toEqual({
|
||||
exists: true,
|
||||
rating: 5,
|
||||
reason: "Great job!",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not call API when eventId is not provided", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: { APP_MODE: "saas" },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(undefined), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not call API when config is not loaded yet", async () => {
|
||||
const { useConfig } = await import("#/hooks/query/use-config");
|
||||
vi.mocked(useConfig).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
} as ReturnType<typeof useConfig>);
|
||||
|
||||
const { result } = renderHook(() => useFeedbackExists(123), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
// Wait for any potential async operations
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
// Verify that the API was not called
|
||||
expect(mockCheckFeedbackExists).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that the query is disabled
|
||||
expect(result.current.data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
105
frontend/__tests__/microagent-status-indicator.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MicroagentStatusIndicator } from "#/components/features/chat/microagent/microagent-status-indicator";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
// Mock the translation hook
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("MicroagentStatusIndicator", () => {
|
||||
it("should show 'View your PR' when status is completed and PR URL is provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should show default completed message when status is completed but no PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", {
|
||||
name: "MICROAGENT$STATUS_COMPLETED",
|
||||
});
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute("href", "/conversations/test-conversation");
|
||||
});
|
||||
|
||||
it("should show creating status without PR URL", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.CREATING}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_CREATING")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show error status", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.ERROR}
|
||||
conversationId="test-conversation"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("MICROAGENT$STATUS_ERROR")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should prioritize PR URL over conversation link when both are provided", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
conversationId="test-conversation"
|
||||
prUrl="https://github.com/owner/repo/pull/123"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
);
|
||||
// Should not link to conversation when PR URL is available
|
||||
expect(link).not.toHaveAttribute(
|
||||
"href",
|
||||
"/conversations/test-conversation",
|
||||
);
|
||||
});
|
||||
|
||||
it("should work with GitLab MR URLs", () => {
|
||||
render(
|
||||
<MicroagentStatusIndicator
|
||||
status={MicroagentStatus.COMPLETED}
|
||||
prUrl="https://gitlab.com/owner/repo/-/merge_requests/456"
|
||||
/>,
|
||||
);
|
||||
|
||||
const link = screen.getByRole("link", { name: "MICROAGENT$VIEW_YOUR_PR" });
|
||||
expect(link).toHaveAttribute(
|
||||
"href",
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
});
|
||||
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
142
frontend/__tests__/parse-pr-url.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractPRUrls,
|
||||
containsPRUrl,
|
||||
getFirstPRUrl,
|
||||
} from "#/utils/parse-pr-url";
|
||||
|
||||
describe("parse-pr-url", () => {
|
||||
describe("extractPRUrls", () => {
|
||||
it("should extract GitHub PR URLs", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should extract GitLab MR URLs", () => {
|
||||
const text =
|
||||
"Merge request: https://gitlab.com/owner/repo/-/merge_requests/456";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Bitbucket PR URLs", () => {
|
||||
const text =
|
||||
"PR link: https://bitbucket.org/owner/repo/pull-requests/789";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://bitbucket.org/owner/repo/pull-requests/789",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract Azure DevOps PR URLs", () => {
|
||||
const text =
|
||||
"Azure PR: https://dev.azure.com/org/project/_git/repo/pullrequest/101";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://dev.azure.com/org/project/_git/repo/pullrequest/101",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should extract multiple PR URLs", () => {
|
||||
const text = `
|
||||
GitHub: https://github.com/owner/repo/pull/123
|
||||
GitLab: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toHaveLength(2);
|
||||
expect(urls).toContain("https://github.com/owner/repo/pull/123");
|
||||
expect(urls).toContain(
|
||||
"https://gitlab.com/owner/repo/-/merge_requests/456",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle self-hosted GitLab URLs", () => {
|
||||
const text =
|
||||
"Self-hosted: https://gitlab.example.com/owner/repo/-/merge_requests/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([
|
||||
"https://gitlab.example.com/owner/repo/-/merge_requests/123",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return empty array when no PR URLs found", () => {
|
||||
const text = "This is just regular text with no PR URLs";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle URLs with HTTP instead of HTTPS", () => {
|
||||
const text = "HTTP PR: http://github.com/owner/repo/pull/123";
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["http://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
|
||||
it("should remove duplicate URLs", () => {
|
||||
const text = `
|
||||
Same PR mentioned twice:
|
||||
https://github.com/owner/repo/pull/123
|
||||
https://github.com/owner/repo/pull/123
|
||||
`;
|
||||
const urls = extractPRUrls(text);
|
||||
expect(urls).toEqual(["https://github.com/owner/repo/pull/123"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("containsPRUrl", () => {
|
||||
it("should return true when PR URL is present", () => {
|
||||
const text = "Check out this PR: https://github.com/owner/repo/pull/123";
|
||||
expect(containsPRUrl(text)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when no PR URL is present", () => {
|
||||
const text = "This is just regular text";
|
||||
expect(containsPRUrl(text)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFirstPRUrl", () => {
|
||||
it("should return the first PR URL found", () => {
|
||||
const text = `
|
||||
First: https://github.com/owner/repo/pull/123
|
||||
Second: https://gitlab.com/owner/repo/-/merge_requests/456
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/123");
|
||||
});
|
||||
|
||||
it("should return null when no PR URL is found", () => {
|
||||
const text = "This is just regular text";
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("real-world scenarios", () => {
|
||||
it("should handle typical microagent finish messages", () => {
|
||||
const text = `
|
||||
I have successfully created a pull request with the requested changes.
|
||||
You can view the PR here: https://github.com/All-Hands-AI/OpenHands/pull/1234
|
||||
|
||||
The changes include:
|
||||
- Updated the component
|
||||
- Added tests
|
||||
- Fixed the issue
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/All-Hands-AI/OpenHands/pull/1234");
|
||||
});
|
||||
|
||||
it("should handle messages with PR URLs in the middle", () => {
|
||||
const text = `
|
||||
Task completed successfully! I've created a pull request at
|
||||
https://github.com/owner/repo/pull/567 with all the requested changes.
|
||||
Please review when you have a chance.
|
||||
`;
|
||||
const url = getFirstPRUrl(text);
|
||||
expect(url).toBe("https://github.com/owner/repo/pull/567");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen from "#/routes/settings";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import SettingsScreen, { clientLoader } from "#/routes/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
// Mock the i18next hook
|
||||
@@ -31,16 +31,27 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("Settings Screen", () => {
|
||||
const { handleLogoutMock } = vi.hoisted(() => ({
|
||||
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
|
||||
handleLogoutMock: vi.fn(),
|
||||
mockQueryClient: (() => {
|
||||
const { QueryClient } = require("@tanstack/react-query");
|
||||
return new QueryClient();
|
||||
})(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-app-logout", () => ({
|
||||
useAppLogout: vi.fn().mockReturnValue({ handleLogout: handleLogoutMock }),
|
||||
}));
|
||||
|
||||
vi.mock("#/query-client-config", () => ({
|
||||
queryClient: mockQueryClient,
|
||||
}));
|
||||
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: SettingsScreen,
|
||||
// @ts-expect-error - custom loader
|
||||
clientLoader,
|
||||
path: "/settings",
|
||||
children: [
|
||||
{
|
||||
@@ -56,8 +67,8 @@ describe("Settings Screen", () => {
|
||||
path: "/settings/app",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="credits-settings-screen" />,
|
||||
path: "/settings/credits",
|
||||
Component: () => <div data-testid="billing-settings-screen" />,
|
||||
path: "/settings/billing",
|
||||
},
|
||||
{
|
||||
Component: () => <div data-testid="api-keys-settings-screen" />,
|
||||
@@ -67,26 +78,27 @@ describe("Settings Screen", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const renderSettingsScreen = (path = "/settings") => {
|
||||
const queryClient = new QueryClient();
|
||||
return render(<RouterStub initialEntries={[path]} />, {
|
||||
const renderSettingsScreen = (path = "/settings") =>
|
||||
render(<RouterStub initialEntries={[path]} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={mockQueryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
it("should render the navbar", async () => {
|
||||
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
|
||||
const sectionsToExclude = ["api keys", "credits"];
|
||||
const sectionsToExclude = ["api keys", "credits", "billing"];
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -102,6 +114,8 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should render the saas navbar", async () => {
|
||||
@@ -113,12 +127,15 @@ describe("Settings Screen", () => {
|
||||
const sectionsToInclude = [
|
||||
"integrations",
|
||||
"application",
|
||||
"credits",
|
||||
"credits", // The nav item shows "credits" text but routes to /billing
|
||||
"secrets",
|
||||
"api keys",
|
||||
];
|
||||
const sectionsToExclude = ["llm"];
|
||||
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
@@ -134,30 +151,44 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
expect(sectionElement).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should not be able to access oss-restricted routes in oss", async () => {
|
||||
it("should not be able to access saas-only routes in oss mode", async () => {
|
||||
const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
|
||||
// @ts-expect-error - only return app mode
|
||||
getConfigSpy.mockResolvedValue({
|
||||
APP_MODE: "oss",
|
||||
});
|
||||
|
||||
const { rerender } = renderSettingsScreen("/settings/credits");
|
||||
// Clear any existing query data
|
||||
mockQueryClient.clear();
|
||||
|
||||
// In OSS mode, accessing restricted routes should redirect to /settings
|
||||
// Since createRoutesStub doesn't handle clientLoader redirects properly,
|
||||
// we test that the correct navbar is shown (OSS navbar) and that
|
||||
// the restricted route components are not rendered when accessing /settings
|
||||
renderSettingsScreen("/settings");
|
||||
|
||||
// Verify we're in OSS mode by checking the navbar
|
||||
const navbar = await screen.findByTestId("settings-navbar");
|
||||
expect(within(navbar).getByText("LLM")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("credits-settings-screen"),
|
||||
within(navbar).queryByText("credits", { exact: false }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
rerender(<RouterStub initialEntries={["/settings/api-keys"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
|
||||
// Verify the LLM settings screen is shown
|
||||
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("billing-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
rerender(<RouterStub initialEntries={["/settings"]} />);
|
||||
expect(
|
||||
screen.queryByTestId("api-keys-settings-screen"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
getConfigSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.todo("should not be able to access saas-restricted routes in saas");
|
||||
it.todo("should not be able to access oss-only routes in saas mode");
|
||||
});
|
||||
|
||||
95
frontend/__tests__/services/actions.test.tsx
Normal file
95
frontend/__tests__/services/actions.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import ActionType from "#/types/action-type";
|
||||
import { ActionMessage } from "#/types/message";
|
||||
|
||||
// Mock the store and actions
|
||||
const mockDispatch = vi.fn();
|
||||
const mockAppendInput = vi.fn();
|
||||
const mockAppendJupyterInput = vi.fn();
|
||||
|
||||
vi.mock("#/store", () => ({
|
||||
default: {
|
||||
dispatch: mockDispatch,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/command-slice", () => ({
|
||||
appendInput: mockAppendInput,
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-slice", () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}));
|
||||
|
||||
describe("handleActionMessage", () => {
|
||||
beforeEach(() => {
|
||||
// Clear all mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should handle RUN actions by adding input to terminal", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const runAction: ActionMessage = {
|
||||
id: 1,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "ls -la",
|
||||
},
|
||||
message: "Running command: ls -la",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(runAction);
|
||||
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendInput("ls -la"));
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
id: 2,
|
||||
source: "agent",
|
||||
action: ActionType.RUN_IPYTHON,
|
||||
args: {
|
||||
code: "print('Hello from Jupyter!')",
|
||||
},
|
||||
message: "Running Python code interactively: print('Hello from Jupyter!')",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockDispatch).toHaveBeenCalledWith(mockAppendJupyterInput("print('Hello from Jupyter!')"));
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not process hidden actions", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const hiddenAction: ActionMessage = {
|
||||
id: 3,
|
||||
source: "agent",
|
||||
action: ActionType.RUN,
|
||||
args: {
|
||||
command: "secret command",
|
||||
hidden: "true",
|
||||
},
|
||||
message: "Running command: secret command",
|
||||
timestamp: "2023-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
// Handle the action
|
||||
handleActionMessage(hiddenAction);
|
||||
|
||||
// Check that nothing was dispatched
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { test, expect, describe, vi } from "vitest";
|
||||
import { HomeHeader } from "#/components/features/home/home-header";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => false,
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("Check for hardcoded English strings in Home components", () => {
|
||||
test("HomeHeader should not have hardcoded English strings", () => {
|
||||
const { container } = render(<HomeHeader />);
|
||||
|
||||
// Get all text content
|
||||
const text = container.textContent;
|
||||
|
||||
// List of English strings that should be translated
|
||||
const hardcodedStrings = [
|
||||
"Launch from Scratch",
|
||||
"Read this",
|
||||
];
|
||||
|
||||
// Check each string
|
||||
hardcodedStrings.forEach((str) => {
|
||||
expect(text).not.toContain(str);
|
||||
});
|
||||
});
|
||||
});
|
||||
1926
frontend/package-lock.json
generated
1926
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,10 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.0-beta.10",
|
||||
"@heroui/react": "^2.8.0-beta.13",
|
||||
"@microlink/react-json-view": "^1.26.2",
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.6.3",
|
||||
@@ -25,20 +25,20 @@
|
||||
"axios": "^1.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"framer-motion": "^12.22.0",
|
||||
"i18next": "^25.3.0",
|
||||
"framer-motion": "^12.23.0",
|
||||
"i18next": "^25.3.2",
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.28",
|
||||
"jose": "^6.0.11",
|
||||
"lucide-react": "^0.525.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.256.0",
|
||||
"posthog-js": "^1.257.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-highlight": "^0.15.0",
|
||||
"react-hot-toast": "^2.5.1",
|
||||
"react-i18next": "^15.5.3",
|
||||
"react-i18next": "^15.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-redux": "^9.2.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"sirv-cli": "^3.0.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vite": "^7.0.0",
|
||||
"vite": "^7.0.3",
|
||||
"web-vitals": "^5.0.3",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
@@ -70,7 +70,6 @@
|
||||
"lint:fix": "eslint src --ext .ts,.tsx,.js --fix && prettier --write src/**/*.{ts,tsx}",
|
||||
"prepare": "cd .. && husky frontend/.husky",
|
||||
"typecheck": "react-router typegen && tsc",
|
||||
"check-unlocalized-strings": "node scripts/check-unlocalized-strings.cjs",
|
||||
"check-translation-completeness": "node scripts/check-translation-completeness.cjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
@@ -80,8 +79,8 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/parser": "^7.27.7",
|
||||
"@babel/traverse": "^7.27.7",
|
||||
"@babel/parser": "^7.28.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.27.0",
|
||||
"@mswjs/socket.io-binding": "^0.2.0",
|
||||
"@playwright/test": "^1.53.2",
|
||||
@@ -92,7 +91,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.0.8",
|
||||
"@types/node": "^24.0.12",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
@@ -107,6 +106,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-airbnb-typescript": "^18.0.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-i18next": "^6.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
@@ -118,7 +118,7 @@
|
||||
"lint-staged": "^16.1.2",
|
||||
"msw": "^2.6.6",
|
||||
"prettier": "^3.6.2",
|
||||
"stripe": "^18.2.1",
|
||||
"stripe": "^18.3.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"typescript": "^5.8.3",
|
||||
"vite-plugin-svgr": "^4.2.0",
|
||||
|
||||
@@ -1,739 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-commit hook script to check for unlocalized strings in the frontend code
|
||||
* This script is based on the test in __tests__/utils/check-hardcoded-strings.test.tsx
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const parser = require('@babel/parser');
|
||||
const traverse = require('@babel/traverse').default;
|
||||
|
||||
// Files/directories to ignore
|
||||
const IGNORE_PATHS = [
|
||||
// Build and dependency files
|
||||
"node_modules",
|
||||
"dist",
|
||||
".git",
|
||||
"test",
|
||||
"__tests__",
|
||||
".d.ts",
|
||||
"i18n",
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"tsconfig.json",
|
||||
|
||||
// Internal code that doesn't need localization
|
||||
"mocks", // Mock data
|
||||
"assets", // SVG paths and CSS classes
|
||||
"types", // Type definitions and constants
|
||||
"state", // Redux state management
|
||||
"api", // API endpoints
|
||||
"services", // Internal services
|
||||
"hooks", // React hooks
|
||||
"context", // React context
|
||||
"store", // Redux store
|
||||
"routes.ts", // Route definitions
|
||||
"root.tsx", // Root component
|
||||
"entry.client.tsx", // Client entry point
|
||||
"utils/scan-unlocalized-strings.ts", // Original scanner
|
||||
"utils/scan-unlocalized-strings-ast.ts", // This file itself
|
||||
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
|
||||
];
|
||||
|
||||
// Extensions to scan
|
||||
const SCAN_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
|
||||
|
||||
// Attributes that typically don't contain user-facing text
|
||||
const NON_TEXT_ATTRIBUTES = [
|
||||
"allow",
|
||||
"className",
|
||||
"i18nKey",
|
||||
"testId",
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"href",
|
||||
"src",
|
||||
"rel",
|
||||
"target",
|
||||
"style",
|
||||
"onClick",
|
||||
"onChange",
|
||||
"onSubmit",
|
||||
"data-testid",
|
||||
"aria-labelledby",
|
||||
"aria-describedby",
|
||||
"aria-hidden",
|
||||
"role",
|
||||
"sandbox",
|
||||
];
|
||||
|
||||
function shouldIgnorePath(filePath) {
|
||||
return IGNORE_PATHS.some((ignore) => filePath.includes(ignore));
|
||||
}
|
||||
|
||||
// Check if a string looks like a translation key
|
||||
// Translation keys typically use dots, underscores, or are all caps
|
||||
// Also check for the pattern with $ which is used in our translation keys
|
||||
function isLikelyTranslationKey(str) {
|
||||
return (
|
||||
/^[A-Z0-9_$.]+$/.test(str) ||
|
||||
str.includes(".") ||
|
||||
/[A-Z0-9_]+\$[A-Z0-9_]+/.test(str)
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a string is a raw translation key that should be wrapped in t()
|
||||
function isRawTranslationKey(str) {
|
||||
// Check for our specific translation key pattern (e.g., "SETTINGS$GITHUB_SETTINGS")
|
||||
// Exclude specific keys that are already properly used with i18next.t() in the code
|
||||
const excludedKeys = [
|
||||
"STATUS$ERROR_LLM_OUT_OF_CREDITS",
|
||||
"ERROR$GENERIC",
|
||||
"GITHUB$AUTH_SCOPE",
|
||||
];
|
||||
|
||||
if (excludedKeys.includes(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str);
|
||||
}
|
||||
|
||||
// Specific technical strings that should be excluded from localization
|
||||
const EXCLUDED_TECHNICAL_STRINGS = [
|
||||
"openid email profile", // OAuth scope string - not user-facing
|
||||
"OPEN_ISSUE", // Task type identifier, not a UI string
|
||||
"Merge Request", // Git provider specific terminology
|
||||
"GitLab API", // Git provider specific terminology
|
||||
"Pull Request", // Git provider specific terminology
|
||||
"GitHub API", // Git provider specific terminology
|
||||
"add-secret-form", // Test ID for secret form
|
||||
"edit-secret-form", // Test ID for secret form
|
||||
"search-api-key-input", // Input name for search API key
|
||||
"noopener,noreferrer", // Options for window.open
|
||||
"STATUS$READY",
|
||||
"STATUS$STOPPED",
|
||||
"STATUS$ERROR",
|
||||
];
|
||||
|
||||
function isExcludedTechnicalString(str) {
|
||||
return EXCLUDED_TECHNICAL_STRINGS.includes(str);
|
||||
}
|
||||
|
||||
function isLikelyCode(str) {
|
||||
// A string with no spaces and at least one underscore or colon is likely a code.
|
||||
// (e.g.: "browser_interactive" or "error:")
|
||||
if (str.includes(" ")) {
|
||||
return false
|
||||
}
|
||||
if (str.includes(":") || str.includes("_")){
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isCommonDevelopmentString(str) {
|
||||
|
||||
// Technical patterns that are definitely not UI strings
|
||||
const technicalPatterns = [
|
||||
// URLs and paths
|
||||
/^https?:\/\//, // URLs
|
||||
/^\/[a-zA-Z0-9_\-./]*$/, // File paths
|
||||
/^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$/, // File extensions, class names
|
||||
/^@[a-zA-Z0-9/-]+$/, // Import paths
|
||||
/^#\/[a-zA-Z0-9/-]+$/, // Alias imports
|
||||
/^[a-zA-Z0-9/-]+\/[a-zA-Z0-9/-]+$/, // Module paths
|
||||
/^data:image\/[a-zA-Z0-9;,]+$/, // Data URLs
|
||||
/^application\/[a-zA-Z0-9-]+$/, // MIME types
|
||||
/^!\[image]\(data:image\/png;base64,$/, // Markdown image with base64 data
|
||||
|
||||
// Numbers, IDs, and technical values
|
||||
/^\d+(\.\d+)?$/, // Numbers
|
||||
/^#[0-9a-fA-F]{3,8}$/, // Color codes
|
||||
/^[a-zA-Z0-9_-]+=[a-zA-Z0-9_-]+$/, // Key-value pairs
|
||||
/^mm:ss$/, // Time format
|
||||
/^[a-zA-Z0-9]+\/[a-zA-Z0-9-]+$/, // Provider/model format
|
||||
/^\?[a-zA-Z0-9_-]+$/, // URL parameters
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID
|
||||
/^[A-Za-z0-9+/=]+$/, // Base64
|
||||
|
||||
// HTML and CSS selectors
|
||||
/^[a-z]+(\[[^\]]+\])+$/, // CSS attribute selectors
|
||||
/^[a-z]+:[a-z-]+$/, // CSS pseudo-selectors
|
||||
/^[a-z]+\.[a-z0-9_-]+$/, // CSS class selectors
|
||||
/^[a-z]+#[a-z0-9_-]+$/, // CSS ID selectors
|
||||
/^[a-z]+\s*>\s*[a-z]+$/, // CSS child selectors
|
||||
/^[a-z]+\s+[a-z]+$/, // CSS descendant selectors
|
||||
|
||||
// CSS and styling patterns
|
||||
/^[a-z0-9-]+:[a-z0-9-]+$/, // CSS property:value
|
||||
/^[a-z0-9-]+:[a-z0-9-]+;[a-z0-9-]+:[a-z0-9-]+$/, // Multiple CSS properties
|
||||
];
|
||||
|
||||
// File extensions and media types
|
||||
const fileExtensionPattern =
|
||||
/^\.(png|jpg|jpeg|gif|svg|webp|bmp|ico|pdf|mp4|webm|ogg|mp3|wav|json|xml|csv|txt|md|html|css|js|jsx|ts|tsx)$/i;
|
||||
if (fileExtensionPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// AI model and provider patterns
|
||||
const aiRelatedPattern =
|
||||
/^(AI|OpenAI|VertexAI|PaLM|Gemini|Anthropic|Anyscale|Databricks|Ollama|FriendliAI|Groq|DeepInfra|AI21|Replicate|OpenRouter|Azure|AWS|SageMaker|Bedrock|Mistral|Perplexity|Fireworks|Cloudflare|Workers|Voyage|claude-|gpt-|o1-|o3-)/i;
|
||||
if (aiRelatedPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// CSS units and values
|
||||
const cssUnitsPattern =
|
||||
/\b\d+(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$|^(px|rem|em|vh|vw|vmin|vmax|ch|ex|fr|deg|rad|turn|grad|ms|s)$/;
|
||||
const cssValuesPattern =
|
||||
/(rgb|rgba|hsl|hsla|#[0-9a-fA-F]+|solid|absolute|relative|sticky|fixed|static|block|inline|flex|grid|none|auto|hidden|visible)/;
|
||||
|
||||
if (cssUnitsPattern.test(str) || cssValuesPattern.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with brackets (common in the codebase)
|
||||
if (
|
||||
str.includes("[") &&
|
||||
str.includes("]") &&
|
||||
(str.includes("px") ||
|
||||
str.includes("rem") ||
|
||||
str.includes("em") ||
|
||||
str.includes("w-") ||
|
||||
str.includes("h-") ||
|
||||
str.includes("p-") ||
|
||||
str.includes("m-"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for CSS class strings with specific patterns
|
||||
if (
|
||||
str.includes("border-") ||
|
||||
str.includes("rounded-") ||
|
||||
str.includes("cursor-") ||
|
||||
str.includes("opacity-") ||
|
||||
str.includes("disabled:") ||
|
||||
str.includes("hover:") ||
|
||||
str.includes("focus-within:") ||
|
||||
str.includes("first-of-type:") ||
|
||||
str.includes("last-of-type:") ||
|
||||
str.includes("group-data-")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it looks like a Tailwind class string
|
||||
if (/^[a-z0-9-]+(\s+[a-z0-9-]+)*$/.test(str)) {
|
||||
// Common Tailwind prefixes and patterns
|
||||
const tailwindPrefixes = [
|
||||
"bg-", "text-", "border-", "rounded-", "p-", "m-", "px-", "py-", "mx-", "my-",
|
||||
"w-", "h-", "min-w-", "min-h-", "max-w-", "max-h-", "flex-", "grid-", "gap-",
|
||||
"space-", "items-", "justify-", "self-", "col-", "row-", "order-", "object-",
|
||||
"overflow-", "opacity-", "z-", "top-", "right-", "bottom-", "left-", "inset-",
|
||||
"font-", "tracking-", "leading-", "list-", "placeholder-", "shadow-", "ring-",
|
||||
"transition-", "duration-", "ease-", "delay-", "animate-", "scale-", "rotate-",
|
||||
"translate-", "skew-", "origin-", "cursor-", "select-", "resize-", "fill-", "stroke-",
|
||||
];
|
||||
|
||||
// Check if any word in the string starts with a Tailwind prefix
|
||||
const words = str.split(/\s+/);
|
||||
for (const word of words) {
|
||||
for (const prefix of tailwindPrefixes) {
|
||||
if (word.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for Tailwind modifiers
|
||||
const tailwindModifiers = [
|
||||
"hover:", "focus:", "active:", "disabled:", "visited:", "first:", "last:",
|
||||
"odd:", "even:", "group-hover:", "focus-within:", "focus-visible:", "motion-safe:",
|
||||
"motion-reduce:", "dark:", "light:", "sm:", "md:", "lg:", "xl:", "2xl:",
|
||||
];
|
||||
|
||||
for (const word of words) {
|
||||
for (const modifier of tailwindModifiers) {
|
||||
if (word.includes(modifier)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for CSS property combinations
|
||||
const cssProperties = [
|
||||
"border", "rounded", "px", "py", "mx", "my", "p", "m", "w", "h", "flex",
|
||||
"grid", "gap", "transition", "duration", "font", "leading", "tracking",
|
||||
];
|
||||
|
||||
// If the string contains multiple CSS properties, it's likely a CSS class string
|
||||
let cssPropertyCount = 0;
|
||||
for (const word of words) {
|
||||
if (
|
||||
cssProperties.some(
|
||||
(prop) => word === prop || word.startsWith(`${prop}-`),
|
||||
)
|
||||
) {
|
||||
cssPropertyCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (cssPropertyCount >= 2) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for specific CSS class patterns that appear in the test failures
|
||||
if (
|
||||
str.match(
|
||||
/^(border|rounded|flex|grid|transition|duration|ease|hover:|focus:|active:|disabled:|placeholder:|text-|bg-|w-|h-|p-|m-|gap-|items-|justify-|self-|overflow-|cursor-|opacity-|z-|top-|right-|bottom-|left-|inset-|font-|tracking-|leading-|whitespace-|break-|truncate|shadow-|ring-|outline-|animate-|transform|rotate-|scale-|skew-|translate-|origin-|first-of-type:|last-of-type:|group-data-|max-|min-|px-|py-|mx-|my-|grow|shrink|resize-|underline|italic|normal)/,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HTML tags and attributes
|
||||
if (
|
||||
/^<[a-z0-9]+(?:\s[^>]*)?>.*<\/[a-z0-9]+>$/i.test(str) ||
|
||||
/^<[a-z0-9]+ [^>]+\/>$/i.test(str)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific patterns in suggestions and examples
|
||||
if (
|
||||
str.includes("* ") &&
|
||||
(str.includes("create a") ||
|
||||
str.includes("build a") ||
|
||||
str.includes("make a"))
|
||||
) {
|
||||
// This is likely a suggestion or example, not a UI string
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for specific technical identifiers from the test failures
|
||||
if (
|
||||
/^(download_via_vscode_button_clicked|open-vscode-error-|set-indicator|settings_saved|openhands-trace-|provider-item-|last_browser_action_error)$/.test(
|
||||
str,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for URL paths and query parameters
|
||||
if (
|
||||
str.startsWith("?") ||
|
||||
str.startsWith("/") ||
|
||||
str.includes("auth.") ||
|
||||
str.includes("$1auth.")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific strings that should be excluded
|
||||
if (
|
||||
str === "Cache Hit:" ||
|
||||
str === "Cache Write:" ||
|
||||
str === "ADD_DOCS" ||
|
||||
str === "ADD_DOCKERFILE" ||
|
||||
str === "Verified" ||
|
||||
str === "Others" ||
|
||||
str === "Feedback" ||
|
||||
str === "JSON File" ||
|
||||
str === "mt-0.5 md:mt-0"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for long suggestion texts
|
||||
if (
|
||||
str.length > 100 &&
|
||||
(str.includes("Please write a bash script") ||
|
||||
str.includes("Please investigate the repo") ||
|
||||
str.includes("Please push the changes") ||
|
||||
str.includes("Examine the dependencies") ||
|
||||
str.includes("Investigate the documentation") ||
|
||||
str.includes("Investigate the current repo") ||
|
||||
str.includes("I want to create a Hello World app") ||
|
||||
str.includes("I want to create a VueJS app") ||
|
||||
str.includes("This should be a client-only app"))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific error messages and UI text
|
||||
if (
|
||||
str === "All data associated with this project will be lost." ||
|
||||
str === "You will lose any unsaved information." ||
|
||||
str ===
|
||||
"This conversation does not exist, or you do not have permission to access it." ||
|
||||
str === "Failed to fetch settings. Please try reloading." ||
|
||||
str ===
|
||||
"If you tell OpenHands to start a web server, the app will appear here." ||
|
||||
str ===
|
||||
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API." ||
|
||||
str ===
|
||||
"Something went wrong while fetching settings. Please reload the page." ||
|
||||
str ===
|
||||
"To help us improve, we collect feedback from your interactions to improve our prompts. By submitting this form, you consent to us collecting this data." ||
|
||||
str === "Please push the latest changes to the existing pull request."
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check against all technical patterns
|
||||
return technicalPatterns.some((pattern) => pattern.test(str));
|
||||
}
|
||||
|
||||
function isLikelyUserFacingText(str) {
|
||||
|
||||
// Basic validation - skip very short strings or strings without letters
|
||||
if (!str || str.length <= 2 || !/[a-zA-Z]/.test(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it's a specifically excluded technical string
|
||||
if (isExcludedTechnicalString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if it looks like a code rather than a key
|
||||
if (isLikelyCode(str)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a raw translation key that should be wrapped in t()
|
||||
if (isRawTranslationKey(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if it's a translation key pattern (e.g., "SETTINGS$BASE_URL")
|
||||
// These should be wrapped in t() or use I18nKey enum
|
||||
if (isLikelyTranslationKey(str) && /^[A-Z0-9_]+\$[A-Z0-9_]+$/.test(str)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First, check if it's a common development string (not user-facing)
|
||||
if (isCommonDevelopmentString(str)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Multi-word phrases are likely UI text
|
||||
const hasMultipleWords = /\s+/.test(str) && str.split(/\s+/).length > 1;
|
||||
|
||||
// Sentences and questions are likely UI text
|
||||
const hasPunctuation = /[?!.,:]/.test(str);
|
||||
const isCapitalizedPhrase = /^[A-Z]/.test(str) && hasMultipleWords;
|
||||
const isTitleCase = hasMultipleWords && /\s[A-Z]/.test(str);
|
||||
const hasSentenceStructure = /^[A-Z].*[.!?]$/.test(str); // Starts with capital, ends with punctuation
|
||||
const hasQuestionForm =
|
||||
/^(What|How|Why|When|Where|Who|Can|Could|Would|Will|Is|Are|Do|Does|Did|Should|May|Might)/.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Product names and camelCase identifiers are likely UI text
|
||||
const hasInternalCapitals = /[a-z][A-Z]/.test(str); // CamelCase product names
|
||||
|
||||
// Instruction text patterns are likely UI text
|
||||
const looksLikeInstruction =
|
||||
/^(Enter|Type|Select|Choose|Provide|Specify|Search|Find|Input|Add|Write|Describe|Set|Pick|Browse|Upload|Download|Click|Tap|Press|Go to|Visit|Open|Close)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Error and status messages are likely UI text
|
||||
const looksLikeErrorOrStatus =
|
||||
/(failed|error|invalid|required|missing|incorrect|wrong|unavailable|not found|not available|try again|success|completed|finished|done|saved|updated|created|deleted|removed|added)/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
// Single word check - assume it's UI text unless proven otherwise
|
||||
const isSingleWord =
|
||||
!str.includes(" ") && str.length > 1 && /^[a-zA-Z]+$/.test(str);
|
||||
|
||||
// For single words, we need to be more careful
|
||||
if (isSingleWord) {
|
||||
// Skip common programming terms and variable names
|
||||
const isCommonProgrammingTerm =
|
||||
/^(null|undefined|true|false|function|class|interface|type|enum|const|let|var|return|import|export|default|async|await|try|catch|finally|throw|new|this|super|extends|implements|instanceof|typeof|void|delete|in|of|for|while|do|if|else|switch|case|break|continue|yield|static|get|set|public|private|protected|readonly|abstract|implements|namespace|module|declare|as|from|with)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonProgrammingTerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common variable name patterns
|
||||
const looksLikeVariableName =
|
||||
/^[a-z][a-zA-Z0-9]*$/.test(str) && str.length <= 20;
|
||||
|
||||
if (looksLikeVariableName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common CSS values
|
||||
const isCommonCssValue =
|
||||
/^(auto|none|hidden|visible|block|inline|flex|grid|row|column|wrap|nowrap|center|start|end|stretch|cover|contain|fixed|absolute|relative|static|sticky|pointer|default|inherit|initial|unset)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonCssValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common file extensions
|
||||
const isFileExtension = /^\.[a-z0-9]+$/i.test(str);
|
||||
if (isFileExtension) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip common abbreviations
|
||||
const isCommonAbbreviation =
|
||||
/^(id|src|href|url|alt|img|btn|nav|div|span|ul|li|ol|dl|dt|dd|svg|png|jpg|gif|pdf|doc|txt|md|js|ts|jsx|tsx|css|scss|less|html|xml|json|yaml|yml|toml|csv|mp3|mp4|wav|avi|mov|mpeg|webm|webp|ttf|woff|eot|otf)$/i.test(
|
||||
str,
|
||||
);
|
||||
|
||||
if (isCommonAbbreviation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's a single word that's not a programming term, variable name, CSS value, file extension, or abbreviation,
|
||||
// it might be UI text, but we'll be conservative and return false
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it has multiple words, punctuation, or looks like a sentence, it's likely UI text
|
||||
return (
|
||||
hasMultipleWords ||
|
||||
hasPunctuation ||
|
||||
isCapitalizedPhrase ||
|
||||
isTitleCase ||
|
||||
hasSentenceStructure ||
|
||||
hasQuestionForm ||
|
||||
hasInternalCapitals ||
|
||||
looksLikeInstruction ||
|
||||
looksLikeErrorOrStatus
|
||||
);
|
||||
}
|
||||
|
||||
function isInTranslationContext(path) {
|
||||
// Check if the JSX text is inside a <Trans> component
|
||||
let current = path;
|
||||
while (current.parentPath) {
|
||||
if (
|
||||
current.isJSXElement() &&
|
||||
current.node.openingElement &&
|
||||
current.node.openingElement.name &&
|
||||
current.node.openingElement.name.name === "Trans"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
current = current.parentPath;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function scanFileForUnlocalizedStrings(filePath) {
|
||||
// Skip suggestion content files as they contain special strings that are already properly localized
|
||||
if (filePath.includes("utils/suggestions/") || filePath.includes("mocks/task-suggestions-handlers.ts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const unlocalizedStrings = [];
|
||||
|
||||
// Skip files that are too large
|
||||
if (content.length > 1000000) {
|
||||
console.warn(`Skipping large file: ${filePath}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the file
|
||||
const ast = parser.parse(content, {
|
||||
sourceType: "module",
|
||||
plugins: ["jsx", "typescript", "classProperties", "decorators-legacy"],
|
||||
});
|
||||
|
||||
// Traverse the AST
|
||||
traverse(ast, {
|
||||
// Find JSX text content
|
||||
JSXText(jsxTextPath) {
|
||||
const text = jsxTextPath.node.value.trim();
|
||||
if (
|
||||
text &&
|
||||
isLikelyUserFacingText(text) &&
|
||||
!isInTranslationContext(jsxTextPath)
|
||||
) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in JSX attributes
|
||||
JSXAttribute(jsxAttrPath) {
|
||||
const attrName = jsxAttrPath.node.name.name.toString();
|
||||
|
||||
// Skip technical attributes that don't contain user-facing text
|
||||
if (NON_TEXT_ATTRIBUTES.includes(attrName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip styling attributes
|
||||
if (
|
||||
attrName === "className" ||
|
||||
attrName === "class" ||
|
||||
attrName === "style"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip data attributes and event handlers
|
||||
if (attrName.startsWith("data-") || attrName.startsWith("on")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the attribute value
|
||||
const value = jsxAttrPath.node.value;
|
||||
if (value && value.type === "StringLiteral") {
|
||||
const text = value.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Find string literals in code
|
||||
StringLiteral(stringPath) {
|
||||
// Skip if parent is JSX attribute (already handled above)
|
||||
if (stringPath.parent.type === "JSXAttribute") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is import/export declaration
|
||||
if (
|
||||
stringPath.parent.type === "ImportDeclaration" ||
|
||||
stringPath.parent.type === "ExportDeclaration"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if parent is object property key
|
||||
if (
|
||||
stringPath.parent.type === "ObjectProperty" &&
|
||||
stringPath.parent.key === stringPath.node
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if inside a t() call or Trans component
|
||||
let isInsideTranslation = false;
|
||||
let current = stringPath;
|
||||
|
||||
while (current.parentPath && !isInsideTranslation) {
|
||||
// Check for t() function call
|
||||
if (
|
||||
current.parent.type === "CallExpression" &&
|
||||
current.parent.callee &&
|
||||
((current.parent.callee.type === "Identifier" &&
|
||||
current.parent.callee.name === "t") ||
|
||||
(current.parent.callee.type === "MemberExpression" &&
|
||||
current.parent.callee.property &&
|
||||
current.parent.callee.property.name === "t"))
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for <Trans> component
|
||||
if (
|
||||
current.parent.type === "JSXElement" &&
|
||||
current.parent.openingElement &&
|
||||
current.parent.openingElement.name &&
|
||||
current.parent.openingElement.name.name === "Trans"
|
||||
) {
|
||||
isInsideTranslation = true;
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.parentPath;
|
||||
}
|
||||
|
||||
if (!isInsideTranslation) {
|
||||
const text = stringPath.node.value.trim();
|
||||
if (text && isLikelyUserFacingText(text)) {
|
||||
unlocalizedStrings.push(text);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return unlocalizedStrings;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${filePath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function scanDirectoryForUnlocalizedStrings(dirPath) {
|
||||
const results = new Map();
|
||||
|
||||
function scanDir(currentPath) {
|
||||
const entries = fs.readdirSync(currentPath, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name);
|
||||
|
||||
if (!shouldIgnorePath(fullPath)) {
|
||||
if (entry.isDirectory()) {
|
||||
scanDir(fullPath);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
SCAN_EXTENSIONS.includes(path.extname(fullPath))
|
||||
) {
|
||||
const unlocalized = scanFileForUnlocalizedStrings(fullPath);
|
||||
if (unlocalized.length > 0) {
|
||||
results.set(fullPath, unlocalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scanDir(dirPath);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Run the check
|
||||
try {
|
||||
const srcPath = path.resolve(__dirname, '../src');
|
||||
console.log('Checking for unlocalized strings in frontend code...');
|
||||
|
||||
// Get unlocalized strings using the AST scanner
|
||||
const results = scanDirectoryForUnlocalizedStrings(srcPath);
|
||||
|
||||
// If we found any unlocalized strings, format them for output and exit with error
|
||||
if (results.size > 0) {
|
||||
const formattedResults = Array.from(results.entries())
|
||||
.map(([file, strings]) => `\n${file}:\n ${strings.join('\n ')}`)
|
||||
.join('\n');
|
||||
|
||||
console.error(`Error: Found unlocalized strings in the following files:${formattedResults}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('✅ No unlocalized strings found in frontend code.');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error running unlocalized strings check:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
21
frontend/src/api/memory-service/memory-service.api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
interface GetPromptResponse {
|
||||
status: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
export class MemoryService {
|
||||
static async getPrompt(
|
||||
conversationId: string,
|
||||
eventId: number,
|
||||
): Promise<string> {
|
||||
const { data } = await openHands.get<GetPromptResponse>(
|
||||
`/api/conversations/${conversationId}/remember_prompt`,
|
||||
{
|
||||
params: { event_id: eventId },
|
||||
},
|
||||
);
|
||||
return data.prompt;
|
||||
}
|
||||
}
|
||||
@@ -258,19 +258,17 @@ class OpenHands {
|
||||
selectedRepository?: string,
|
||||
git_provider?: Provider,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
suggested_task?: SuggestedTask,
|
||||
selected_branch?: string,
|
||||
conversationInstructions?: string,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
repository: selectedRepository,
|
||||
git_provider,
|
||||
selected_branch,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
replay_json: replayJson,
|
||||
suggested_task,
|
||||
conversation_instructions: conversationInstructions,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RuntimeStatus } from "#/types/runtime-status";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
@@ -70,6 +71,12 @@ export interface AuthenticateResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface RepositorySelection {
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: Provider | null;
|
||||
}
|
||||
|
||||
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
|
||||
|
||||
export interface Conversation {
|
||||
@@ -77,7 +84,7 @@ export interface Conversation {
|
||||
title: string;
|
||||
selected_repository: string | null;
|
||||
selected_branch: string | null;
|
||||
git_provider: string | null;
|
||||
git_provider: Provider | null;
|
||||
last_updated_at: string;
|
||||
created_at: string;
|
||||
status: ConversationStatus;
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChatInput({
|
||||
disabled,
|
||||
showButton = true,
|
||||
value,
|
||||
maxRows = 16,
|
||||
maxRows = 8,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onChange,
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ErrorMessageBanner } from "./error-message-banner";
|
||||
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";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -92,9 +93,12 @@ export function ChatInterface() {
|
||||
|
||||
const handleSendMessage = async (
|
||||
content: string,
|
||||
images: File[],
|
||||
files: File[],
|
||||
originalImages: File[],
|
||||
originalFiles: File[],
|
||||
) => {
|
||||
// Create mutable copies of the arrays
|
||||
const images = [...originalImages];
|
||||
const files = [...originalFiles];
|
||||
if (events.length === 0) {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: getEntryPoint(
|
||||
@@ -110,6 +114,16 @@ export function ChatInterface() {
|
||||
current_message_length: content.length,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate file sizes before any processing
|
||||
const allFiles = [...images, ...files];
|
||||
const validation = validateFiles(allFiles);
|
||||
|
||||
if (!validation.isValid) {
|
||||
displayErrorToast(`Error: ${validation.errorMessage}`);
|
||||
return; // Stop processing if validation fails
|
||||
}
|
||||
|
||||
const promises = images.map((image) => convertImageToBase64(image));
|
||||
const imageUrls = await Promise.all(promises);
|
||||
|
||||
|
||||
@@ -12,12 +12,17 @@ import { paragraph } from "../markdown/paragraph";
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
message: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
type,
|
||||
message,
|
||||
children,
|
||||
actions,
|
||||
}: React.PropsWithChildren<ChatMessageProps>) {
|
||||
const [isHovering, setIsHovering] = React.useState(false);
|
||||
const [isCopy, setIsCopy] = React.useState(false);
|
||||
@@ -47,31 +52,54 @@ export function ChatMessage({
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={cn(
|
||||
"rounded-xl relative",
|
||||
"rounded-xl relative w-fit",
|
||||
"flex flex-col gap-2",
|
||||
type === "user" && " max-w-[305px] p-4 bg-tertiary self-end",
|
||||
type === "agent" && "mt-6 max-w-full bg-transparent",
|
||||
)}
|
||||
>
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
<div className="text-sm break-words">
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute -top-2.5 -right-2.5",
|
||||
!isHovering ? "hidden" : "flex",
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
isDisabled={isCopy}
|
||||
onClick={handleCopyToClipboard}
|
||||
mode={isCopy ? "copied" : "copy"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-sm break-words flex">
|
||||
<div>
|
||||
<Markdown
|
||||
components={{
|
||||
code,
|
||||
ul,
|
||||
ol,
|
||||
a: anchor,
|
||||
p: paragraph,
|
||||
}}
|
||||
remarkPlugins={[remarkGfm]}
|
||||
>
|
||||
{message}
|
||||
</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</article>
|
||||
|
||||
@@ -19,6 +19,8 @@ import { MCPObservationContent } from "./mcp-observation-content";
|
||||
import { getObservationResult } from "./event-content-helpers/get-observation-result";
|
||||
import { getEventContent } from "./event-content-helpers/get-event-content";
|
||||
import { GenericEventMessage } from "./generic-event-message";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { MicroagentStatusIndicator } from "./microagent/microagent-status-indicator";
|
||||
import { FileList } from "../files/file-list";
|
||||
import { parseMessageFromEvent } from "./event-content-helpers/parse-message-from-event";
|
||||
import { LikertScale } from "../feedback/likert-scale";
|
||||
@@ -35,6 +37,13 @@ interface EventMessageProps {
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -43,6 +52,10 @@ export function EventMessage({
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
@@ -82,27 +95,66 @@ export function EventMessage({
|
||||
|
||||
if (isErrorObservation(event)) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<ErrorMessage
|
||||
errorId={event.extras.error_id}
|
||||
defaultMessage={event.message}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasObservationPair && isOpenHandsAction(event)) {
|
||||
if (hasThoughtProperty(event.args)) {
|
||||
return <ChatMessage type="agent" message={event.args.thought} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={event.args.thought}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
return microagentStatus && actions ? (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isFinishAction(event)) {
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type="agent" message={getEventContent(event).details} />
|
||||
<ChatMessage
|
||||
type="agent"
|
||||
message={getEventContent(event).details}
|
||||
actions={actions}
|
||||
/>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{renderLikertScale()}
|
||||
</>
|
||||
);
|
||||
@@ -112,8 +164,8 @@ export function EventMessage({
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatMessage type={event.source} message={message}>
|
||||
<div className="flex flex-col self-end">
|
||||
<ChatMessage type={event.source} message={message} actions={actions}>
|
||||
{event.args.image_urls && event.args.image_urls.length > 0 && (
|
||||
<ImageCarousel size="small" images={event.args.image_urls} />
|
||||
)}
|
||||
@@ -122,15 +174,26 @@ export function EventMessage({
|
||||
)}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
{microagentStatus && actions && (
|
||||
<MicroagentStatusIndicator
|
||||
status={microagentStatus}
|
||||
conversationId={microagentConversationId}
|
||||
prUrl={microagentPRUrl}
|
||||
/>
|
||||
)}
|
||||
{isAssistantMessage(event) &&
|
||||
event.action === "message" &&
|
||||
renderLikertScale()}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRejectObservation(event)) {
|
||||
return <ChatMessage type="agent" message={event.content} />;
|
||||
return (
|
||||
<div>
|
||||
<ChatMessage type="agent" message={event.content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMcpObservation(event)) {
|
||||
|
||||
@@ -5,6 +5,8 @@ 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";
|
||||
|
||||
interface InteractiveChatBoxProps {
|
||||
isDisabled?: boolean;
|
||||
@@ -27,14 +29,20 @@ export function InteractiveChatBox({
|
||||
const [files, setFiles] = React.useState<File[]>([]);
|
||||
|
||||
const handleUpload = (selectedFiles: File[]) => {
|
||||
setFiles((prevFiles) => [
|
||||
...prevFiles,
|
||||
...selectedFiles.filter((f) => !isFileImage(f)),
|
||||
]);
|
||||
setImages((prevImages) => [
|
||||
...prevImages,
|
||||
...selectedFiles.filter((f) => isFileImage(f)),
|
||||
]);
|
||||
// Validate files before adding them
|
||||
const validation = validateFiles(selectedFiles, [...images, ...files]);
|
||||
|
||||
if (!validation.isValid) {
|
||||
displayErrorToast(`Error: ${validation.errorMessage}`);
|
||||
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));
|
||||
|
||||
setFiles((prevFiles) => [...prevFiles, ...validFiles]);
|
||||
setImages((prevImages) => [...prevImages, ...validImages]);
|
||||
};
|
||||
|
||||
const removeElementByIndex = (array: Array<File>, index: number) => {
|
||||
|
||||
@@ -1,10 +1,28 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import { isOpenHandsAction, isOpenHandsObservation } from "#/types/core/guards";
|
||||
import {
|
||||
isOpenHandsAction,
|
||||
isOpenHandsObservation,
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isFinishAction,
|
||||
} from "#/types/core/guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { LaunchMicroagentModal } from "./microagent/launch-microagent-modal";
|
||||
import { useUserConversation } from "#/hooks/query/use-user-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
|
||||
import {
|
||||
MicroagentStatus,
|
||||
EventMicroagentStatus,
|
||||
} from "#/types/microagent-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { getFirstPRUrl } from "#/utils/parse-pr-url";
|
||||
import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: (OpenHandsAction | OpenHandsObservation)[];
|
||||
@@ -13,10 +31,23 @@ interface MessagesProps {
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
const { createConversationAndSubscribe, isPending } =
|
||||
useCreateConversationAndSubscribeMultiple();
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useUserConversation(conversationId);
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const [selectedEventId, setSelectedEventId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [showLaunchMicroagentModal, setShowLaunchMicroagentModal] =
|
||||
React.useState(false);
|
||||
const [microagentStatuses, setMicroagentStatuses] = React.useState<
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -30,6 +61,139 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
[messages],
|
||||
);
|
||||
|
||||
const getMicroagentStatusForEvent = React.useCallback(
|
||||
(eventId: number): MicroagentStatus | null => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.status || null;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentConversationIdForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.conversationId || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const getMicroagentPRUrlForEvent = React.useCallback(
|
||||
(eventId: number): string | undefined => {
|
||||
const statusEntry = microagentStatuses.find(
|
||||
(entry) => entry.eventId === eventId,
|
||||
);
|
||||
return statusEntry?.prUrl || undefined;
|
||||
},
|
||||
[microagentStatuses],
|
||||
);
|
||||
|
||||
const handleMicroagentEvent = React.useCallback(
|
||||
(socketEvent: unknown, microagentConversationId: string) => {
|
||||
// Handle error events
|
||||
const isErrorEvent = (
|
||||
evt: unknown,
|
||||
): evt is { error: true; message: string } =>
|
||||
typeof evt === "object" &&
|
||||
evt !== null &&
|
||||
"error" in evt &&
|
||||
evt.error === true;
|
||||
|
||||
const isAgentStatusError = (evt: unknown): boolean =>
|
||||
isOpenHandsEvent(evt) &&
|
||||
isAgentStateChangeObservation(evt) &&
|
||||
evt.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
if (isErrorEvent(socketEvent) || isAgentStatusError(socketEvent)) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.ERROR }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isAgentStateChangeObservation(socketEvent)
|
||||
) {
|
||||
if (socketEvent.extras.agent_state === AgentState.FINISHED) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? { ...statusEntry, status: MicroagentStatus.COMPLETED }
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(socketEvent) &&
|
||||
isFinishAction(socketEvent)
|
||||
) {
|
||||
// Check if the finish action contains a PR URL
|
||||
const prUrl = getFirstPRUrl(socketEvent.args.final_thought || "");
|
||||
if (prUrl) {
|
||||
setMicroagentStatuses((prev) =>
|
||||
prev.map((statusEntry) =>
|
||||
statusEntry.conversationId === microagentConversationId
|
||||
? {
|
||||
...statusEntry,
|
||||
status: MicroagentStatus.COMPLETED,
|
||||
prUrl,
|
||||
}
|
||||
: statusEntry,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[setMicroagentStatuses],
|
||||
);
|
||||
|
||||
const handleLaunchMicroagent = (
|
||||
query: string,
|
||||
target: string,
|
||||
triggers: string[],
|
||||
) => {
|
||||
const conversationInstructions = `Target file: ${target}\n\nDescription: ${query}\n\nTriggers: ${triggers.join(", ")}`;
|
||||
if (
|
||||
!conversation ||
|
||||
!conversation.selected_repository ||
|
||||
!conversation.selected_branch ||
|
||||
!conversation.git_provider ||
|
||||
!selectedEventId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
createConversationAndSubscribe({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository: {
|
||||
name: conversation.selected_repository,
|
||||
branch: conversation.selected_branch,
|
||||
gitProvider: conversation.git_provider,
|
||||
},
|
||||
onSuccessCallback: (newConversationId: string) => {
|
||||
setShowLaunchMicroagentModal(false);
|
||||
// Update status with conversation ID
|
||||
setMicroagentStatuses((prev) => [
|
||||
...prev.filter((status) => status.eventId !== selectedEventId),
|
||||
{
|
||||
eventId: selectedEventId,
|
||||
conversationId: newConversationId,
|
||||
status: MicroagentStatus.CREATING,
|
||||
},
|
||||
]);
|
||||
},
|
||||
onEventCallback: (socketEvent: unknown, newConversationId: string) => {
|
||||
handleMicroagentEvent(socketEvent, newConversationId);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{messages.map((message, index) => (
|
||||
@@ -39,6 +203,26 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
microagentStatus={getMicroagentStatusForEvent(message.id)}
|
||||
microagentConversationId={getMicroagentConversationIdForEvent(
|
||||
message.id,
|
||||
)}
|
||||
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
|
||||
actions={
|
||||
conversation?.selected_repository
|
||||
? [
|
||||
{
|
||||
icon: (
|
||||
<MemoryIcon className="w-[14px] h-[14px] text-white" />
|
||||
),
|
||||
onClick: () => {
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
/>
|
||||
))}
|
||||
@@ -46,6 +230,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
{optimisticUserMessage && (
|
||||
<ChatMessage type="user" message={optimisticUserMessage} />
|
||||
)}
|
||||
{conversation?.selected_repository &&
|
||||
showLaunchMicroagentModal &&
|
||||
selectedEventId &&
|
||||
createPortal(
|
||||
<LaunchMicroagentModal
|
||||
onClose={() => setShowLaunchMicroagentModal(false)}
|
||||
onLaunch={handleLaunchMicroagent}
|
||||
selectedRepo={
|
||||
conversation.selected_repository.split("/").pop() || ""
|
||||
}
|
||||
eventId={selectedEventId}
|
||||
isLoading={isPending}
|
||||
/>,
|
||||
document.getElementById("modal-portal-exit") || document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../../settings/brand-button";
|
||||
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
import { cn } from "#/utils/utils";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { useMicroagentPrompt } from "#/hooks/query/use-microagent-prompt";
|
||||
import { useHandleRuntimeActive } from "#/hooks/use-handle-runtime-active";
|
||||
import { LoadingMicroagentBody } from "./loading-microagent-body";
|
||||
import { LoadingMicroagentTextarea } from "./loading-microagent-textarea";
|
||||
import { useGetMicroagents } from "#/hooks/query/use-get-microagents";
|
||||
|
||||
interface LaunchMicroagentModalProps {
|
||||
onClose: () => void;
|
||||
onLaunch: (query: string, target: string, triggers: string[]) => void;
|
||||
eventId: number;
|
||||
isLoading: boolean;
|
||||
selectedRepo: string;
|
||||
}
|
||||
|
||||
export function LaunchMicroagentModal({
|
||||
onClose,
|
||||
onLaunch,
|
||||
eventId,
|
||||
isLoading,
|
||||
selectedRepo,
|
||||
}: LaunchMicroagentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { runtimeActive } = useHandleRuntimeActive();
|
||||
const { data: prompt, isLoading: promptIsLoading } =
|
||||
useMicroagentPrompt(eventId);
|
||||
|
||||
const { data: microagents, isLoading: microagentsIsLoading } =
|
||||
useGetMicroagents(`${selectedRepo}/.openhands/microagents`);
|
||||
|
||||
const [triggers, setTriggers] = React.useState<string[]>([]);
|
||||
|
||||
const formAction = (formData: FormData) => {
|
||||
const query = formData.get("query-input")?.toString();
|
||||
const target = formData.get("target-input")?.toString();
|
||||
|
||||
if (query && target) {
|
||||
onLaunch(query, target, triggers);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(event.currentTarget);
|
||||
formAction(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
{!runtimeActive && <LoadingMicroagentBody />}
|
||||
{runtimeActive && (
|
||||
<ModalBody className="items-start w-[728px]">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
{t("MICROAGENT$WHAT_TO_REMEMBER")}
|
||||
{promptIsLoading && <LoadingMicroagentTextarea />}
|
||||
{!promptIsLoading && (
|
||||
<textarea
|
||||
required
|
||||
data-testid="query-input"
|
||||
name="query-input"
|
||||
defaultValue={prompt}
|
||||
placeholder={t("MICROAGENT$DESCRIBE_WHAT_TO_ADD")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="target-input"
|
||||
name="target-input"
|
||||
label={t("MICROAGENT$WHERE_TO_PUT")}
|
||||
placeholder={t("MICROAGENT$SELECT_FILE_OR_CUSTOM")}
|
||||
required
|
||||
allowsCustomValue
|
||||
isLoading={microagentsIsLoading}
|
||||
items={
|
||||
microagents?.map((item) => ({
|
||||
key: item,
|
||||
label: item,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="trigger-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TRIGGERS")}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
<BadgeInput
|
||||
name="trigger-input"
|
||||
value={triggers}
|
||||
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
|
||||
onChange={setTriggers}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<BrandButton type="button" variant="secondary" onClick={onClose}>
|
||||
{t("MICROAGENT$CANCEL")}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={
|
||||
isLoading || promptIsLoading || microagentsIsLoading
|
||||
}
|
||||
>
|
||||
{t("MICROAGENT$LAUNCH")}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</ModalBody>
|
||||
)}
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
|
||||
export function LoadingMicroagentBody() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBody>
|
||||
<h2 className="font-bold text-[20px] leading-6 -tracking-[0.01em] flex items-center gap-2">
|
||||
{t("MICROAGENT$ADD_TO_MICROAGENT")}
|
||||
</h2>
|
||||
<Spinner size="lg" />
|
||||
<p>{t("MICROAGENT$WAIT_FOR_RUNTIME")}</p>
|
||||
</ModalBody>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
export function LoadingMicroagentTextarea() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<textarea
|
||||
required
|
||||
disabled
|
||||
defaultValue=""
|
||||
placeholder={t("MICROAGENT$LOADING_PROMPT")}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] w-full rounded p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { MicroagentStatus } from "#/types/microagent-status";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface MicroagentStatusIndicatorProps {
|
||||
status: MicroagentStatus;
|
||||
conversationId?: string;
|
||||
prUrl?: string;
|
||||
}
|
||||
|
||||
export function MicroagentStatusIndicator({
|
||||
status,
|
||||
conversationId,
|
||||
prUrl,
|
||||
}: MicroagentStatusIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return t("MICROAGENT$STATUS_CREATING");
|
||||
case MicroagentStatus.COMPLETED:
|
||||
// If there's a PR URL, show "View your PR" instead of the default completed message
|
||||
return prUrl
|
||||
? t("MICROAGENT$VIEW_YOUR_PR")
|
||||
: t("MICROAGENT$STATUS_COMPLETED");
|
||||
case MicroagentStatus.ERROR:
|
||||
return t("MICROAGENT$STATUS_ERROR");
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
switch (status) {
|
||||
case MicroagentStatus.CREATING:
|
||||
return <Spinner size="sm" />;
|
||||
case MicroagentStatus.COMPLETED:
|
||||
return <SuccessIndicator status="success" />;
|
||||
case MicroagentStatus.ERROR:
|
||||
return <SuccessIndicator status="error" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const statusText = getStatusText();
|
||||
const shouldShowAsLink = !!conversationId;
|
||||
const shouldShowPRLink = !!prUrl;
|
||||
|
||||
const renderStatusText = () => {
|
||||
if (shouldShowPRLink) {
|
||||
return (
|
||||
<a
|
||||
href={prUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldShowAsLink) {
|
||||
return (
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{statusText}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="underline">{statusText}</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-2 p-2 text-sm">
|
||||
{getStatusIcon()}
|
||||
{renderStatusText()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import toast from "react-hot-toast";
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOAST_OPTIONS } from "#/utils/custom-toast-handlers";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { SuccessIndicator } from "../success-indicator";
|
||||
|
||||
interface ConversationCreatedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationCreatedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationCreatedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Spinner size="sm" />
|
||||
<div>
|
||||
{t("MICROAGENT$ADDING_CONTEXT")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationFinishedToastProps {
|
||||
conversationId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationFinishedToast({
|
||||
conversationId,
|
||||
onClose,
|
||||
}: ConversationFinishedToastProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="success" />
|
||||
<div>
|
||||
{t("MICROAGENT$SUCCESS_PR_READY")}
|
||||
<br />
|
||||
<a
|
||||
href={`/conversations/${conversationId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
{t("MICROAGENT$VIEW_CONVERSATION")}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationErroredToastProps {
|
||||
errorMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ConversationErroredToast({
|
||||
errorMessage,
|
||||
onClose,
|
||||
}: ConversationErroredToastProps) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<SuccessIndicator status="error" />
|
||||
<div>{errorMessage}</div>
|
||||
<button type="button" onClick={onClose}>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const renderConversationCreatedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationCreatedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationFinishedToast = (conversationId: string) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationFinishedToast
|
||||
conversationId={conversationId}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
|
||||
export const renderConversationErroredToast = (
|
||||
conversationId: string,
|
||||
errorMessage: string,
|
||||
) =>
|
||||
toast(
|
||||
(t) => (
|
||||
<ConversationErroredToast
|
||||
errorMessage={errorMessage}
|
||||
onClose={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{
|
||||
...TOAST_OPTIONS,
|
||||
id: `status-${conversationId}`,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
@@ -4,6 +4,7 @@ import { AgentStatusBar } from "./agent-status-bar";
|
||||
import { SecurityLock } from "./security-lock";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { ConversationCard } from "../conversation-panel/conversation-card";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface ControlsProps {
|
||||
setSecurityOpen: (isOpen: boolean) => void;
|
||||
@@ -29,7 +30,11 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
|
||||
showOptions
|
||||
title={conversation?.title ?? ""}
|
||||
lastUpdatedAt={conversation?.created_at ?? ""}
|
||||
selectedRepository={conversation?.selected_repository ?? null}
|
||||
selectedRepository={{
|
||||
selected_repository: conversation?.selected_repository ?? null,
|
||||
selected_branch: conversation?.selected_branch ?? null,
|
||||
git_provider: (conversation?.git_provider as Provider) ?? null,
|
||||
}}
|
||||
conversationStatus={conversation?.status}
|
||||
conversationId={conversation?.conversation_id}
|
||||
/>
|
||||
|
||||
@@ -17,8 +17,12 @@ export function BudgetUsageText({
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-neutral-400">
|
||||
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
|
||||
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
|
||||
{t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, {
|
||||
currentCost: `$${currentCost.toFixed(4)}`,
|
||||
maxBudget: `$${maxBudget.toFixed(4)}`,
|
||||
usagePercentage: usagePercentage.toFixed(2),
|
||||
used: t(I18nKey.CONVERSATION$USED),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,17 +43,17 @@ export function ConversationCardContextMenu({
|
||||
>
|
||||
{onDelete && (
|
||||
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
{t(I18nKey.BUTTON$DELETE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
Stop
|
||||
{t(I18nKey.BUTTON$STOP)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
{t(I18nKey.BUTTON$EDIT_TITLE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDownloadViaVSCode && (
|
||||
@@ -61,7 +61,7 @@ export function ConversationCardContextMenu({
|
||||
testId="download-vscode-button"
|
||||
onClick={onDownloadViaVSCode}
|
||||
>
|
||||
Download via VS Code
|
||||
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDisplayCost && (
|
||||
@@ -69,7 +69,7 @@ export function ConversationCardContextMenu({
|
||||
testId="display-cost-button"
|
||||
onClick={onDisplayCost}
|
||||
>
|
||||
Display Cost
|
||||
{t(I18nKey.BUTTON$DISPLAY_COST)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onShowAgentTools && (
|
||||
@@ -77,7 +77,7 @@ export function ConversationCardContextMenu({
|
||||
testId="show-agent-tools-button"
|
||||
onClick={onShowAgentTools}
|
||||
>
|
||||
Show Agent Tools & Metadata
|
||||
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onShowMicroagents && (
|
||||
|
||||
@@ -19,6 +19,7 @@ import OpenHands from "#/api/open-hands";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { isSystemMessage } from "#/types/core/guards";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface ConversationCardProps {
|
||||
onClick?: () => void;
|
||||
@@ -28,7 +29,7 @@ interface ConversationCardProps {
|
||||
showOptions?: boolean;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
@@ -180,7 +181,7 @@ export function ConversationCard({
|
||||
data-testid="conversation-card"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"h-[100px] w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
|
||||
"h-auto w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
|
||||
variant === "compact" &&
|
||||
"md:w-fit h-auto rounded-xl border border-[#525252]",
|
||||
)}
|
||||
@@ -264,28 +265,33 @@ export function ConversationCard({
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
variant === "compact" && "flex items-center justify-between mt-1",
|
||||
variant === "compact" && "flex flex-col justify-between mt-1",
|
||||
)}
|
||||
>
|
||||
{selectedRepository && (
|
||||
<ConversationRepoLink selectedRepository={selectedRepository} />
|
||||
{selectedRepository?.selected_repository && (
|
||||
<ConversationRepoLink
|
||||
selectedRepository={selectedRepository}
|
||||
variant={variant}
|
||||
/>
|
||||
)}
|
||||
{(createdAt || lastUpdatedAt) && (
|
||||
<p className="text-xs text-neutral-400">
|
||||
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
{showUpdateTime && (
|
||||
<>
|
||||
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-400">
|
||||
<span>{t(I18nKey.CONVERSATION$CREATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(createdAt || lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
{showUpdateTime && (
|
||||
<>
|
||||
<span>{t(I18nKey.CONVERSATION$UPDATED)} </span>
|
||||
<time>
|
||||
{formatTimeDelta(new Date(lastUpdatedAt))}{" "}
|
||||
{t(I18nKey.CONVERSATION$AGO)}
|
||||
</time>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,11 +330,15 @@ export function ConversationCard({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
|
||||
<span className="text-neutral-400">Cache Hit:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_HIT)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_read_tokens.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-neutral-400">Cache Write:</span>
|
||||
<span className="text-neutral-400">
|
||||
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
|
||||
</span>
|
||||
<span className="text-right">
|
||||
{metrics.usage.cache_write_tokens.toLocaleString()}
|
||||
</span>
|
||||
@@ -403,10 +413,7 @@ export function ConversationCard({
|
||||
/>
|
||||
|
||||
{microagentsModalVisible && (
|
||||
<MicroagentsModal
|
||||
onClose={() => setMicroagentsModalVisible(false)}
|
||||
conversationId={conversationId}
|
||||
/>
|
||||
<MicroagentsModal onClose={() => setMicroagentsModalVisible(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmStopModal } from "./confirm-stop-modal";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { ExitConversationModal } from "./exit-conversation-modal";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface ConversationPanelProps {
|
||||
onClose: () => void;
|
||||
@@ -114,7 +115,11 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
onDelete={() => handleDeleteProject(project.conversation_id)}
|
||||
onStop={() => handleStopConversation(project.conversation_id)}
|
||||
title={project.title}
|
||||
selectedRepository={project.selected_repository}
|
||||
selectedRepository={{
|
||||
selected_repository: project.selected_repository,
|
||||
selected_branch: project.selected_branch,
|
||||
git_provider: project.git_provider as Provider,
|
||||
}}
|
||||
lastUpdatedAt={project.last_updated_at}
|
||||
createdAt={project.created_at}
|
||||
conversationStatus={project.status}
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
|
||||
import { RepositorySelection } from "#/api/open-hands.types";
|
||||
|
||||
interface ConversationRepoLinkProps {
|
||||
selectedRepository: string;
|
||||
selectedRepository: RepositorySelection;
|
||||
variant: "compact" | "default";
|
||||
}
|
||||
|
||||
export function ConversationRepoLink({
|
||||
selectedRepository,
|
||||
variant = "default",
|
||||
}: ConversationRepoLinkProps) {
|
||||
if (variant === "compact") {
|
||||
return (
|
||||
<span
|
||||
data-testid="conversation-card-selected-repository"
|
||||
className="text-xs text-neutral-400"
|
||||
>
|
||||
{selectedRepository.selected_repository}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="conversation-card-selected-repository"
|
||||
className="text-xs text-neutral-400"
|
||||
>
|
||||
{selectedRepository}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{selectedRepository.git_provider === "github" && <FaGithub size={14} />}
|
||||
{selectedRepository.git_provider === "gitlab" && <FaGitlab />}
|
||||
{selectedRepository.git_provider === "bitbucket" && <FaBitbucket />}
|
||||
|
||||
<span
|
||||
data-testid="conversation-card-selected-repository"
|
||||
className="text-xs text-neutral-400"
|
||||
>
|
||||
{selectedRepository.selected_repository}
|
||||
</span>
|
||||
<code
|
||||
data-testid="conversation-card-selected-branch"
|
||||
className="text-xs text-neutral-400 border border-neutral-700 rounded px-1 py-0.5 w-fit bg-neutral-800"
|
||||
>
|
||||
{selectedRepository.selected_branch}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
|
||||
import { BaseModalTitle } from "#/components/shared/modals/confirmation-modals/base-modal";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useConversationMicroagents } from "#/hooks/query/use-conversation-microagents";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
|
||||
interface MicroagentsModalProps {
|
||||
onClose: () => void;
|
||||
conversationId: string | undefined;
|
||||
}
|
||||
|
||||
export function MicroagentsModal({
|
||||
onClose,
|
||||
conversationId,
|
||||
}: MicroagentsModalProps) {
|
||||
export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const [expandedAgents, setExpandedAgents] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const {
|
||||
data: microagents,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useConversationMicroagents({
|
||||
conversationId,
|
||||
enabled: true,
|
||||
});
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationMicroagents();
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
setExpandedAgents((prev) => ({
|
||||
@@ -37,6 +36,10 @@ export function MicroagentsModal({
|
||||
}));
|
||||
};
|
||||
|
||||
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
|
||||
curAgentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody
|
||||
@@ -45,10 +48,40 @@ export function MicroagentsModal({
|
||||
testID="microagents-modal"
|
||||
>
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
|
||||
{isAgentReady && (
|
||||
<BrandButton
|
||||
testId="refresh-microagents"
|
||||
type="button"
|
||||
variant="primary"
|
||||
className="flex items-center gap-2"
|
||||
onClick={refetch}
|
||||
isDisabled={isLoading || isRefetching}
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`${isRefetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t(I18nKey.BUTTON$REFRESH)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAgentReady && (
|
||||
<span className="text-sm text-gray-400">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$WARNING)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="w-full h-[60vh] overflow-auto rounded-md">
|
||||
{!isAgentReady && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t(I18nKey.DIFF_VIEWER$WAITING_FOR_RUNTIME)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary" />
|
||||
@@ -56,6 +89,7 @@ export function MicroagentsModal({
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !microagents || microagents.length === 0) && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-gray-400">
|
||||
@@ -66,75 +100,81 @@ export function MicroagentsModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && microagents && microagents.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{microagents.map((agent) => {
|
||||
const isExpanded = expandedAgents[agent.name] || false;
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
microagents &&
|
||||
microagents.length > 0 && (
|
||||
<div className="p-2 space-y-3">
|
||||
{microagents.map((agent) => {
|
||||
const isExpanded = expandedAgents[agent.name] || false;
|
||||
|
||||
return (
|
||||
<div key={agent.name} className="rounded-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAgent(agent.name)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
return (
|
||||
<div
|
||||
key={agent.name}
|
||||
className="rounded-md overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-bold text-gray-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{agent.type === "repo" ? "Repository" : "Knowledge"}
|
||||
</span>
|
||||
<span className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAgent(agent.name)}
|
||||
className="w-full py-3 px-2 text-left flex items-center justify-between hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-bold text-gray-100">
|
||||
{agent.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="px-2 py-1 text-xs rounded-full bg-gray-800 mr-2">
|
||||
{agent.type === "repo" ? "Repository" : "Knowledge"}
|
||||
</span>
|
||||
<span className="text-gray-300">
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={18} />
|
||||
) : (
|
||||
<ChevronRight size={18} />
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
{agent.triggers && agent.triggers.length > 0 && (
|
||||
<div className="mt-2 mb-3">
|
||||
{isExpanded && (
|
||||
<div className="px-2 pb-3 pt-1">
|
||||
{agent.triggers && agent.triggers.length > 0 && (
|
||||
<div className="mt-2 mb-3">
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.triggers.map((trigger) => (
|
||||
<span
|
||||
key={trigger}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-900"
|
||||
>
|
||||
{trigger}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
|
||||
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{agent.triggers.map((trigger) => (
|
||||
<span
|
||||
key={trigger}
|
||||
className="px-2 py-1 text-xs rounded-full bg-blue-900"
|
||||
>
|
||||
{trigger}
|
||||
</span>
|
||||
))}
|
||||
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
||||
{agent.content ||
|
||||
t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2">
|
||||
<h4 className="text-sm font-semibold text-gray-300 mb-2">
|
||||
{t(I18nKey.MICROAGENTS_MODAL$CONTENT)}
|
||||
</h4>
|
||||
<div className="text-sm mt-2 p-3 bg-gray-900 rounded-md overflow-auto text-gray-300 max-h-[400px] shadow-inner">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed">
|
||||
{agent.content ||
|
||||
t(I18nKey.MICROAGENTS_MODAL$NO_CONTENT)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
|
||||
@@ -207,7 +207,7 @@ export function LikertScale({
|
||||
className={cn("text-xl transition-all", getButtonClass(rating))}
|
||||
aria-label={`Rate ${rating} stars`}
|
||||
>
|
||||
★
|
||||
{t(I18nKey.FEEDBACK$STAR_RATING)}
|
||||
</button>
|
||||
))}
|
||||
{/* Show selected reason inline with stars when submitted (only for ratings <= 3) */}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import AllHandsLogo from "#/assets/branding/all-hands-logo-spark.svg?react";
|
||||
|
||||
export function HomeHeader() {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
mutate: createConversation,
|
||||
isPending,
|
||||
@@ -28,7 +30,15 @@ export function HomeHeader() {
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
onClick={() =>
|
||||
createConversation(
|
||||
{},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && t("HOME$LAUNCH_FROM_SCRATCH")}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { RepoProviderLinks } from "./repo-provider-links";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
interface RepoConnectorProps {
|
||||
onRepoSelection: (repoTitle: string | null) => void;
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
}
|
||||
|
||||
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
export function RepoProviderLinks() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const githubHref = config
|
||||
? `https://github.com/apps/${config.APP_SLUG}/installations/new`
|
||||
: "";
|
||||
|
||||
const hasGithubProvider = providers.includes("github");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col text-sm underline underline-offset-2 text-content-2 gap-4 w-fit">
|
||||
<a href={githubHref} target="_blank" rel="noopener noreferrer">
|
||||
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
|
||||
</a>
|
||||
{hasGithubProvider && (
|
||||
<a href={githubHref} target="_blank" rel="noopener noreferrer">
|
||||
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, test, expect, vi, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RepositorySelectionForm } from "./repo-selection-form";
|
||||
|
||||
// Create mock functions
|
||||
const mockUseUserRepositories = vi.fn();
|
||||
const mockUseRepositoryBranches = vi.fn();
|
||||
const mockUseCreateConversation = vi.fn();
|
||||
const mockUseIsCreatingConversation = vi.fn();
|
||||
const mockUseTranslation = vi.fn();
|
||||
const mockUseAuth = vi.fn();
|
||||
|
||||
// Setup default mock returns
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseRepositoryBranches.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversation.mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
mockUseIsCreatingConversation.mockReturnValue(false);
|
||||
|
||||
mockUseTranslation.mockReturnValue({ t: (key: string) => key });
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
// Mock the modules
|
||||
vi.mock("#/hooks/query/use-user-repositories", () => ({
|
||||
useUserRepositories: () => mockUseUserRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-repository-branches", () => ({
|
||||
useRepositoryBranches: () => mockUseRepositoryBranches(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-create-conversation", () => ({
|
||||
useCreateConversation: () => mockUseCreateConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-is-creating-conversation", () => ({
|
||||
useIsCreatingConversation: () => mockUseIsCreatingConversation(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => mockUseTranslation(),
|
||||
}));
|
||||
|
||||
vi.mock("#/context/auth-context", () => ({
|
||||
useAuth: () => mockUseAuth(),
|
||||
}));
|
||||
|
||||
const renderRepositorySelectionForm = () =>
|
||||
render(<RepositorySelectionForm onRepoSelection={vi.fn()} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows loading indicator when repositories are being fetched", () => {
|
||||
// Setup loading state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if loading indicator is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-loading")).toBeInTheDocument();
|
||||
expect(screen.getByText("HOME$LOADING_REPOSITORIES")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows dropdown when repositories are loaded", () => {
|
||||
// Setup loaded repositories
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
full_name: "user/repo2",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if dropdown is displayed
|
||||
expect(screen.getByTestId("repo-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error message when repository fetch fails", () => {
|
||||
// Setup error state
|
||||
mockUseUserRepositories.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
error: new Error("Failed to fetch repositories"),
|
||||
});
|
||||
|
||||
renderRepositorySelectionForm();
|
||||
|
||||
// Check if error message is displayed
|
||||
expect(screen.getByTestId("repo-dropdown-error")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("HOME$FAILED_TO_LOAD_REPOSITORIES"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@@ -19,12 +20,13 @@ import {
|
||||
} from "./repository-selection";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repoTitle: string | null) => void;
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
}
|
||||
|
||||
export function RepositorySelectionForm({
|
||||
onRepoSelection,
|
||||
}: RepositorySelectionFormProps) {
|
||||
const navigate = useNavigate();
|
||||
const [selectedRepository, setSelectedRepository] =
|
||||
React.useState<GitRepository | null>(null);
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
@@ -94,8 +96,7 @@ export function RepositorySelectionForm({
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
|
||||
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
setSelectedRepository(selectedRepo || null);
|
||||
setSelectedBranch(null); // Reset branch selection when repo changes
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
@@ -209,10 +210,19 @@ export function RepositorySelectionForm({
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() =>
|
||||
createConversation({
|
||||
selectedRepository,
|
||||
selected_branch: selectedBranch?.name,
|
||||
})
|
||||
createConversation(
|
||||
{
|
||||
repository: {
|
||||
name: selectedRepository?.full_name || "",
|
||||
gitProvider: selectedRepository?.git_provider || "github",
|
||||
branch: selectedBranch?.name || "main",
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (data) =>
|
||||
navigate(`/conversations/${data.conversation_id}`),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
|
||||
@@ -3,9 +3,7 @@ import { SuggestedTask } from "./task.types";
|
||||
import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
|
||||
const getTaskTypeMap = (
|
||||
@@ -23,28 +21,19 @@ interface TaskCardProps {
|
||||
|
||||
export function TaskCard({ task }: TaskCardProps) {
|
||||
const { setOptimisticUserMessage } = useOptimisticUserMessage();
|
||||
const { data: repositories } = useUserRepositories();
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const isCreatingConversation = useIsCreatingConversation();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getRepo = (repo: string, git_provider: Provider) => {
|
||||
const selectedRepo = repositories?.find(
|
||||
(repository) =>
|
||||
repository.full_name === repo &&
|
||||
repository.git_provider === git_provider,
|
||||
);
|
||||
|
||||
return selectedRepo;
|
||||
};
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
setOptimisticUserMessage(t("TASK$ADDRESSING_TASK"));
|
||||
|
||||
return createConversation({
|
||||
selectedRepository: repo,
|
||||
suggested_task: task,
|
||||
repository: {
|
||||
name: task.repo,
|
||||
gitProvider: task.git_provider,
|
||||
},
|
||||
suggestedTask: task,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -64,7 +53,7 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
|
||||
<li className="py-3 border-b border-[#717888] flex items-center pr-6 last:border-b-0">
|
||||
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
|
||||
|
||||
<div className="w-full pl-8">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
|
||||
import { TaskCard } from "./task-card";
|
||||
import { TaskItemTitle } from "./task-item-title";
|
||||
import { SuggestedTask } from "./task.types";
|
||||
@@ -8,9 +9,16 @@ interface TaskGroupProps {
|
||||
}
|
||||
|
||||
export function TaskGroup({ title, tasks }: TaskGroupProps) {
|
||||
const gitProvider = tasks.length > 0 ? tasks[0].git_provider : null;
|
||||
|
||||
return (
|
||||
<div className="text-content-2">
|
||||
<TaskItemTitle>{title}</TaskItemTitle>
|
||||
<div className="flex items-center gap-2 border-b-1 border-[#717888]">
|
||||
{gitProvider === "github" && <FaGithub size={14} />}
|
||||
{gitProvider === "gitlab" && <FaGitlab />}
|
||||
{gitProvider === "bitbucket" && <FaBitbucket />}
|
||||
<TaskItemTitle>{title}</TaskItemTitle>
|
||||
</div>
|
||||
|
||||
<ul className="text-sm">
|
||||
{tasks.map((task) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
|
||||
return (
|
||||
<div className="py-3 border-b-1 border-[#717888]">
|
||||
<div className="py-3">
|
||||
<h3 className="text-[16px] leading-6 font-[500]">{title}</h3>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,16 +6,24 @@ import { TaskSuggestionsSkeleton } from "./task-suggestions-skeleton";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
interface TaskSuggestionsProps {
|
||||
filterFor?: string | null;
|
||||
filterFor?: GitRepository | null;
|
||||
}
|
||||
|
||||
export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: tasks, isLoading } = useSuggestedTasks();
|
||||
|
||||
const suggestedTasks = filterFor
|
||||
? tasks?.filter((task) => task.title === filterFor)
|
||||
? tasks?.filter(
|
||||
(element) =>
|
||||
element.title === filterFor.full_name &&
|
||||
!!element.tasks.find(
|
||||
(task) => task.git_provider === filterFor.git_provider,
|
||||
),
|
||||
)
|
||||
: tasks;
|
||||
|
||||
const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RootState } from "#/store";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { JupyterCell } from "./jupyter-cell";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
@@ -11,28 +13,43 @@ interface JupyterEditorProps {
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const cells = useSelector((state: RootState) => state.jupyter?.cells ?? []);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const jupyterRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
<>
|
||||
{isRuntimeInactive && (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isRuntimeInactive && (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { FaTrash } from "react-icons/fa6";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
@@ -108,7 +109,12 @@ export function ApiKeysManager() {
|
||||
<tbody>
|
||||
{apiKeys.map((key) => (
|
||||
<tr key={key.id} className="border-t border-tertiary">
|
||||
<td className="p-3 text-sm">{key.name}</td>
|
||||
<td
|
||||
className="p-3 text-sm truncate max-w-[160px]"
|
||||
title={key.name}
|
||||
>
|
||||
{key.name}
|
||||
</td>
|
||||
<td className="p-3 text-sm">
|
||||
{formatDate(key.created_at)}
|
||||
</td>
|
||||
@@ -118,13 +124,14 @@ export function ApiKeysManager() {
|
||||
<td className="p-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="underline"
|
||||
onClick={() => {
|
||||
setKeyToDelete(key);
|
||||
setDeleteModalOpen(true);
|
||||
}}
|
||||
aria-label={`Delete ${key.name}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{t(I18nKey.BUTTON$DELETE)}
|
||||
<FaTrash size={16} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -73,7 +73,7 @@ export function DeleteApiKeyModal({
|
||||
footer={modalFooter}
|
||||
>
|
||||
<div data-testid="delete-api-key-modal">
|
||||
<p className="text-sm">
|
||||
<p className="text-sm break-all">
|
||||
{t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
|
||||
name: keyToDelete.name,
|
||||
})}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
|
||||
interface ResetSettingsModalProps {
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ResetSettingsModal({ onReset }: ResetSettingsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<div className="bg-base-secondary p-4 rounded-xl flex flex-col gap-4 border border-tertiary">
|
||||
<p>{t(I18nKey.SETTINGS$RESET_CONFIRMATION)}</p>
|
||||
<div className="w-full flex gap-2" data-testid="reset-settings-modal">
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
type="submit"
|
||||
name="reset-settings"
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Reset
|
||||
</BrandButton>
|
||||
|
||||
<BrandButton
|
||||
testId="cancel-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
onClick={onReset}
|
||||
>
|
||||
Cancel
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
|
||||
className="text-sm text-blue-400 hover:underline mr-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Documentation
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
export function OptionalTag() {
|
||||
return <span className="text-xs text-tertiary-alt">(Optional)</span>;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-xs text-tertiary-alt">
|
||||
{t(I18nKey.COMMON$OPTIONAL)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useCreateSecret } from "#/hooks/mutation/use-create-secret";
|
||||
import { useUpdateSecret } from "#/hooks/mutation/use-update-secret";
|
||||
import { SettingsInput } from "../settings-input";
|
||||
@@ -151,7 +152,7 @@ export function SecretForm({
|
||||
|
||||
{mode === "add" && (
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<span className="text-sm">Value</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$VALUE)}</span>
|
||||
<textarea
|
||||
data-testid="value-input"
|
||||
name="secret-value"
|
||||
@@ -168,7 +169,7 @@ export function SecretForm({
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">Description</span>
|
||||
<span className="text-sm">{t(I18nKey.FORM$DESCRIPTION)}</span>
|
||||
<OptionalTag />
|
||||
</div>
|
||||
<input
|
||||
@@ -190,7 +191,7 @@ export function SecretForm({
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton testId="submit-button" type="submit" variant="primary">
|
||||
{mode === "add" && t("SECRETS$ADD_SECRET")}
|
||||
|
||||
@@ -32,20 +32,26 @@ export function SecretListItem({
|
||||
return (
|
||||
<tr
|
||||
data-testid="secret-item"
|
||||
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
|
||||
className="flex w-full items-center border-t border-tertiary"
|
||||
>
|
||||
<td className="w-1/4 text-sm text-content-2">{title}</td>
|
||||
|
||||
<td className="w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic">
|
||||
{description || "-"}
|
||||
<td className="p-3 w-1/4 text-sm text-content-2 truncate" title={title}>
|
||||
{title}
|
||||
</td>
|
||||
|
||||
<td className="w-1/4 flex items-center justify-end gap-4">
|
||||
<td
|
||||
className="p-3 w-1/2 truncate overflow-hidden whitespace-nowrap text-sm text-content-2 opacity-80 italic"
|
||||
title={description || ""}
|
||||
>
|
||||
{description || ""}
|
||||
</td>
|
||||
|
||||
<td className="p-3 w-1/4 flex items-center justify-end gap-4">
|
||||
<button
|
||||
data-testid="edit-secret-button"
|
||||
type="button"
|
||||
onClick={onEdit}
|
||||
aria-label={`Edit ${title}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FaPencil size={16} />
|
||||
</button>
|
||||
@@ -54,6 +60,7 @@ export function SecretListItem({
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={`Delete ${title}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FaTrash size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Autocomplete, AutocompleteItem } from "@heroui/react";
|
||||
import { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { OptionalTag } from "./optional-tag";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
@@ -12,9 +13,12 @@ interface SettingsDropdownInputProps {
|
||||
placeholder?: string;
|
||||
showOptionalTag?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
defaultSelectedKey?: string;
|
||||
selectedKey?: string;
|
||||
isClearable?: boolean;
|
||||
allowsCustomValue?: boolean;
|
||||
required?: boolean;
|
||||
onSelectionChange?: (key: React.Key | null) => void;
|
||||
onInputChange?: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
@@ -29,13 +33,17 @@ export function SettingsDropdownInput({
|
||||
placeholder,
|
||||
showOptionalTag,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
defaultSelectedKey,
|
||||
selectedKey,
|
||||
isClearable,
|
||||
allowsCustomValue,
|
||||
required,
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
}: SettingsDropdownInputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<label className={cn("flex flex-col gap-2.5", wrapperClassName)}>
|
||||
{label && (
|
||||
@@ -54,8 +62,11 @@ export function SettingsDropdownInput({
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
isClearable={isClearable}
|
||||
isDisabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
isLoading={isLoading}
|
||||
placeholder={isLoading ? t("HOME$LOADING") : placeholder}
|
||||
allowsCustomValue={allowsCustomValue}
|
||||
isRequired={required}
|
||||
className="w-full"
|
||||
classNames={{
|
||||
popoverContent: "bg-tertiary rounded-xl border border-[#717888]",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { StyledSwitchComponent } from "./styled-switch-component";
|
||||
|
||||
interface SettingsSwitchProps {
|
||||
@@ -19,6 +21,7 @@ export function SettingsSwitch({
|
||||
isToggled: controlledIsToggled,
|
||||
isBeta,
|
||||
}: React.PropsWithChildren<SettingsSwitchProps>) {
|
||||
const { t } = useTranslation();
|
||||
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
|
||||
|
||||
const handleToggle = (value: boolean) => {
|
||||
@@ -44,7 +47,7 @@ export function SettingsSwitch({
|
||||
<span className="text-sm">{children}</span>
|
||||
{isBeta && (
|
||||
<span className="text-[11px] leading-4 text-[#0D0F11] font-[500] tracking-tighter bg-primary px-1 rounded-full">
|
||||
Beta
|
||||
{t(I18nKey.BADGE$BETA)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,14 @@ function Terminal() {
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
)}
|
||||
<div ref={ref} className="h-full w-full" />
|
||||
<div
|
||||
ref={ref}
|
||||
className={
|
||||
isRuntimeInactive
|
||||
? "w-0 h-0 opacity-0 overflow-hidden"
|
||||
: "h-full w-full"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
21
frontend/src/components/shared/badge.tsx
Normal file
21
frontend/src/components/shared/badge.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface BrandBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function BrandBadge({
|
||||
children,
|
||||
className,
|
||||
}: React.PropsWithChildren<BrandBadgeProps>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm leading-4 text-[#0D0F11] font-semibold tracking-tighter bg-primary p-1 rounded-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export function CopyToClipboardButton({
|
||||
aria-label={t(
|
||||
mode === "copy" ? I18nKey.BUTTON$COPY : I18nKey.BUTTON$COPIED,
|
||||
)}
|
||||
className="button-base p-1 absolute top-1 right-1"
|
||||
className="button-base p-1 cursor-pointer"
|
||||
>
|
||||
{mode === "copy" && <CopyIcon width={15} height={15} />}
|
||||
{mode === "copied" && <CheckmarkIcon width={15} height={15} />}
|
||||
|
||||
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
75
frontend/src/components/shared/inputs/badge-input.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { FaX } from "react-icons/fa6";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BrandBadge } from "../badge";
|
||||
|
||||
interface BadgeInputProps {
|
||||
name?: string;
|
||||
value: string[];
|
||||
placeholder?: string;
|
||||
onChange: (value: string[]) => void;
|
||||
}
|
||||
|
||||
export function BadgeInput({
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: BadgeInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
// If pressing Backspace with empty input, remove the last badge
|
||||
if (e.key === "Backspace" && inputValue === "" && value.length > 0) {
|
||||
const newBadges = [...value];
|
||||
newBadges.pop();
|
||||
onChange(newBadges);
|
||||
return;
|
||||
}
|
||||
|
||||
// If pressing Space or Enter with non-empty input, add a new badge
|
||||
if (e.key === " " && inputValue.trim() !== "") {
|
||||
e.preventDefault();
|
||||
const newBadge = inputValue.trim();
|
||||
onChange([...value, newBadge]);
|
||||
setInputValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeBadge = (indexToRemove: number) => {
|
||||
onChange(value.filter((_, index) => index !== indexToRemove));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] rounded w-full p-2 placeholder:italic placeholder:text-tertiary-alt",
|
||||
"flex flex-wrap items-center gap-2",
|
||||
)}
|
||||
>
|
||||
{value.map((badge, index) => (
|
||||
<div key={index}>
|
||||
<BrandBadge className="flex items-center gap-0.5">
|
||||
{badge}
|
||||
<button
|
||||
data-testid="remove-button"
|
||||
type="button"
|
||||
onClick={() => removeBadge(index)}
|
||||
>
|
||||
<FaX className="w-3 h-3 text-black" />
|
||||
</button>
|
||||
</BrandBadge>
|
||||
</div>
|
||||
))}
|
||||
<input
|
||||
data-testid={name || "badge-input"}
|
||||
name={name}
|
||||
value={inputValue}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-grow outline-none bg-transparent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import { ModalBackdrop } from "./modal-backdrop";
|
||||
|
||||
@@ -12,6 +14,7 @@ export function ConfirmationModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmationModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<ModalBackdrop onClose={onCancel}>
|
||||
<div
|
||||
@@ -27,7 +30,7 @@ export function ConfirmationModal({
|
||||
variant="secondary"
|
||||
className="grow"
|
||||
>
|
||||
Cancel
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="confirm-button"
|
||||
@@ -36,7 +39,7 @@ export function ConfirmationModal({
|
||||
variant="primary"
|
||||
className="grow"
|
||||
>
|
||||
Confirm
|
||||
{t(I18nKey.BUTTON$CONFIRM)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
315
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
315
frontend/src/context/conversation-subscriptions-provider.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { OpenHandsParsedEvent } from "#/types/core";
|
||||
import {
|
||||
isOpenHandsEvent,
|
||||
isAgentStateChangeObservation,
|
||||
isStatusUpdate,
|
||||
} from "#/types/core/guards";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import {
|
||||
renderConversationErroredToast,
|
||||
renderConversationCreatedToast,
|
||||
renderConversationFinishedToast,
|
||||
} from "#/components/features/chat/microagent/microagent-status-toast";
|
||||
|
||||
interface ConversationSocket {
|
||||
socket: Socket;
|
||||
isConnected: boolean;
|
||||
events: OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
interface ConversationSubscriptionsContextType {
|
||||
activeConversationIds: string[];
|
||||
subscribeToConversation: (options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => void;
|
||||
unsubscribeFromConversation: (conversationId: string) => void;
|
||||
isSubscribedToConversation: (conversationId: string) => boolean;
|
||||
getEventsForConversation: (conversationId: string) => OpenHandsParsedEvent[];
|
||||
}
|
||||
|
||||
const ConversationSubscriptionsContext =
|
||||
createContext<ConversationSubscriptionsContextType>({
|
||||
activeConversationIds: [],
|
||||
subscribeToConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
unsubscribeFromConversation: () => {
|
||||
throw new Error("ConversationSubscriptionsProvider not initialized");
|
||||
},
|
||||
isSubscribedToConversation: () => false,
|
||||
getEventsForConversation: () => [],
|
||||
});
|
||||
|
||||
const isErrorEvent = (
|
||||
event: unknown,
|
||||
): event is { error: true; message: string } =>
|
||||
typeof event === "object" &&
|
||||
event !== null &&
|
||||
"error" in event &&
|
||||
event.error === true &&
|
||||
"message" in event &&
|
||||
typeof event.message === "string";
|
||||
|
||||
const isAgentStatusError = (event: unknown): event is OpenHandsParsedEvent =>
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event) &&
|
||||
event.extras.agent_state === AgentState.ERROR;
|
||||
|
||||
export function ConversationSubscriptionsProvider({
|
||||
children,
|
||||
}: React.PropsWithChildren) {
|
||||
const [activeConversationIds, setActiveConversationIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const [conversationSockets, setConversationSockets] = useState<
|
||||
Record<string, ConversationSocket>
|
||||
>({});
|
||||
const eventHandlersRef = useRef<Record<string, (event: unknown) => void>>({});
|
||||
|
||||
// Cleanup function to remove all subscriptions when component unmounts
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Store the current sockets in a local variable to avoid closure issues
|
||||
const socketsToDisconnect = { ...conversationSockets };
|
||||
|
||||
Object.values(socketsToDisconnect).forEach((socketData) => {
|
||||
if (socketData.socket) {
|
||||
socketData.socket.removeAllListeners();
|
||||
socketData.socket.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const unsubscribeFromConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
// Get a local reference to the socket data to avoid race conditions
|
||||
const socketData = conversationSockets[conversationId];
|
||||
|
||||
if (socketData) {
|
||||
const { socket } = socketData;
|
||||
const handler = eventHandlersRef.current[conversationId];
|
||||
|
||||
if (socket) {
|
||||
if (handler) {
|
||||
socket.off("oh_event", handler);
|
||||
}
|
||||
socket.removeAllListeners();
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
// Update state to remove the socket
|
||||
setConversationSockets((prev) => {
|
||||
const newSockets = { ...prev };
|
||||
delete newSockets[conversationId];
|
||||
return newSockets;
|
||||
});
|
||||
|
||||
// Remove from active IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.filter((id) => id !== conversationId),
|
||||
);
|
||||
|
||||
// Clean up event handler reference
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const subscribeToConversation = useCallback(
|
||||
(options: {
|
||||
conversationId: string;
|
||||
sessionApiKey: string | null;
|
||||
providersSet: ("github" | "gitlab" | "bitbucket")[];
|
||||
baseUrl: string;
|
||||
onEvent?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
const { conversationId, sessionApiKey, providersSet, baseUrl, onEvent } =
|
||||
options;
|
||||
|
||||
// If already subscribed, don't create a new subscription
|
||||
if (conversationSockets[conversationId]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleOhEvent = (event: unknown) => {
|
||||
// Call the custom event handler if provided
|
||||
if (onEvent) {
|
||||
onEvent(event, conversationId);
|
||||
}
|
||||
|
||||
// Update the events for this subscription
|
||||
if (isOpenHandsEvent(event)) {
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
events: [...(prev[conversationId]?.events || []), event],
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Handle error events
|
||||
if (isErrorEvent(event) || isAgentStatusError(event)) {
|
||||
renderConversationErroredToast(
|
||||
conversationId,
|
||||
isErrorEvent(event)
|
||||
? event.message
|
||||
: "Unknown error, please try again",
|
||||
);
|
||||
} else if (isStatusUpdate(event)) {
|
||||
if (event.type === "info" && event.id === "STATUS$STARTING_RUNTIME") {
|
||||
renderConversationCreatedToast(conversationId);
|
||||
}
|
||||
} else if (
|
||||
isOpenHandsEvent(event) &&
|
||||
isAgentStateChangeObservation(event)
|
||||
) {
|
||||
if (event.extras.agent_state === AgentState.FINISHED) {
|
||||
renderConversationFinishedToast(conversationId);
|
||||
unsubscribeFromConversation(conversationId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Store the event handler in ref for cleanup
|
||||
eventHandlersRef.current[conversationId] = handleOhEvent;
|
||||
|
||||
try {
|
||||
// Create socket connection
|
||||
const socket = io(baseUrl, {
|
||||
transports: ["websocket"],
|
||||
query: {
|
||||
conversation_id: conversationId,
|
||||
session_api_key: sessionApiKey,
|
||||
providers_set: providersSet,
|
||||
},
|
||||
reconnection: true,
|
||||
reconnectionAttempts: 5,
|
||||
reconnectionDelay: 1000,
|
||||
});
|
||||
|
||||
// Set up event listeners
|
||||
socket.on("connect", () => {
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("connect_error", (error) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} CONNECTION ERROR:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} DISCONNECTED! Reason:`,
|
||||
reason,
|
||||
);
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
...prev[conversationId],
|
||||
isConnected: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("oh_event", handleOhEvent);
|
||||
|
||||
// Add the socket to our state first
|
||||
setConversationSockets((prev) => ({
|
||||
...prev,
|
||||
[conversationId]: {
|
||||
socket,
|
||||
isConnected: socket.connected,
|
||||
events: [],
|
||||
},
|
||||
}));
|
||||
|
||||
// Then add to active conversation IDs
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.includes(conversationId) ? prev : [...prev, conversationId],
|
||||
);
|
||||
} catch (error) {
|
||||
// Clean up the event handler if there was an error
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
},
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const isSubscribedToConversation = useCallback(
|
||||
(conversationId: string) => !!conversationSockets[conversationId],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const getEventsForConversation = useCallback(
|
||||
(conversationId: string) =>
|
||||
conversationSockets[conversationId]?.events || [],
|
||||
[conversationSockets],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
}),
|
||||
[
|
||||
activeConversationIds,
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
getEventsForConversation,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ConversationSubscriptionsContext.Provider value={value}>
|
||||
{children}
|
||||
</ConversationSubscriptionsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useConversationSubscriptions() {
|
||||
return useContext(ConversationSubscriptionsContext);
|
||||
}
|
||||
@@ -328,6 +328,7 @@ export function WsClientProvider({
|
||||
transports: ["websocket"],
|
||||
query,
|
||||
});
|
||||
|
||||
sio.on("connect", handleConnect);
|
||||
sio.on("oh_event", handleMessage);
|
||||
sio.on("connect_error", handleError);
|
||||
|
||||
@@ -67,6 +67,7 @@ prepareApp().then(() =>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HydratedRouter />
|
||||
<PosthogInit />
|
||||
<div id="modal-portal-exit" />
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -1,58 +1,47 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import posthog from "posthog-js";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
repository?: {
|
||||
name: string;
|
||||
gitProvider: Provider;
|
||||
branch?: string;
|
||||
};
|
||||
suggestedTask?: SuggestedTask;
|
||||
conversationInstructions?: string;
|
||||
}
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { selectedRepository, files, replayJson } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
q?: string;
|
||||
selectedRepository?: GitRepository | null;
|
||||
selected_branch?: string;
|
||||
suggested_task?: SuggestedTask;
|
||||
}) => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
mutationFn: async (variables: CreateConversationVariables) => {
|
||||
const { query, repository, suggestedTask, conversationInstructions } =
|
||||
variables;
|
||||
|
||||
return OpenHands.createConversation(
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.full_name
|
||||
: undefined,
|
||||
variables.selectedRepository
|
||||
? variables.selectedRepository.git_provider
|
||||
: undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
variables.suggested_task || undefined,
|
||||
variables.selected_branch,
|
||||
repository?.name,
|
||||
repository?.gitProvider,
|
||||
query,
|
||||
suggestedTask,
|
||||
repository?.branch,
|
||||
conversationInstructions,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: q?.length,
|
||||
has_repository: !!selectedRepository,
|
||||
has_files: files.length > 0,
|
||||
has_replay_json: !!replayJson,
|
||||
query_character_length: query?.length,
|
||||
has_repository: !!repository,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
navigate(`/conversations/${conversationId}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
|
||||
interface UseConversationMicroagentsOptions {
|
||||
conversationId: string | undefined;
|
||||
enabled?: boolean;
|
||||
}
|
||||
export const useConversationMicroagents = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
export const useConversationMicroagents = ({
|
||||
conversationId,
|
||||
enabled = true,
|
||||
}: UseConversationMicroagentsOptions) =>
|
||||
useQuery({
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "microagents"],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) {
|
||||
@@ -19,7 +18,11 @@ export const useConversationMicroagents = ({
|
||||
const data = await OpenHands.getMicroagents(conversationId);
|
||||
return data.microagents;
|
||||
},
|
||||
enabled: !!conversationId && enabled,
|
||||
enabled:
|
||||
!!conversationId &&
|
||||
curAgentState !== AgentState.LOADING &&
|
||||
curAgentState !== AgentState.INIT,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
|
||||
export interface FeedbackData {
|
||||
exists: boolean;
|
||||
@@ -10,6 +11,7 @@ export interface FeedbackData {
|
||||
|
||||
export const useFeedbackExists = (eventId?: number) => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
return useQuery<FeedbackData>({
|
||||
queryKey: ["feedback", "exists", conversationId, eventId],
|
||||
@@ -17,7 +19,7 @@ export const useFeedbackExists = (eventId?: number) => {
|
||||
if (!eventId) return { exists: false };
|
||||
return OpenHands.checkFeedbackExists(conversationId, eventId);
|
||||
},
|
||||
enabled: !!eventId,
|
||||
enabled: !!eventId && config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useGetMicroagentPrompt = ({ eventId }: { eventId: number }) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", "remember_prompt", conversationId, eventId],
|
||||
queryFn: () => OpenHands.getMicroagentPrompt(conversationId, eventId),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
15
frontend/src/hooks/query/use-get-microagents.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
export const useGetMicroagents = (microagentDirectory: string) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", "microagents", conversationId, microagentDirectory],
|
||||
queryFn: () => FileService.getFiles(conversationId!, microagentDirectory),
|
||||
enabled: !!conversationId,
|
||||
select: (data) =>
|
||||
data.map((fileName) => fileName.replace(microagentDirectory, "")),
|
||||
});
|
||||
};
|
||||
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
15
frontend/src/hooks/query/use-microagent-prompt.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemoryService } from "#/api/memory-service/memory-service.api";
|
||||
import { useConversationId } from "../use-conversation-id";
|
||||
|
||||
export const useMicroagentPrompt = (eventId: number) => {
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["memory", "prompt", conversationId, eventId],
|
||||
queryFn: () => MemoryService.getPrompt(conversationId!, eventId),
|
||||
enabled: !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from "react";
|
||||
import { useCreateConversation } from "./mutation/use-create-conversation";
|
||||
import { useUserProviders } from "./use-user-providers";
|
||||
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
/**
|
||||
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
|
||||
* This extends the functionality of useCreateConversationAndSubscribe to allow subscribing to
|
||||
* multiple conversations simultaneously.
|
||||
*/
|
||||
export const useCreateConversationAndSubscribeMultiple = () => {
|
||||
const { mutate: createConversation, isPending } = useCreateConversation();
|
||||
const { providers } = useUserProviders();
|
||||
const {
|
||||
subscribeToConversation,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
} = useConversationSubscriptions();
|
||||
|
||||
const createConversationAndSubscribe = React.useCallback(
|
||||
({
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
onSuccessCallback,
|
||||
onEventCallback,
|
||||
}: {
|
||||
query: string;
|
||||
conversationInstructions: string;
|
||||
repository: {
|
||||
name: string;
|
||||
branch: string;
|
||||
gitProvider: Provider;
|
||||
};
|
||||
onSuccessCallback?: (conversationId: string) => void;
|
||||
onEventCallback?: (event: unknown, conversationId: string) => void;
|
||||
}) => {
|
||||
createConversation(
|
||||
{
|
||||
query,
|
||||
conversationInstructions,
|
||||
repository,
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
let baseUrl = "";
|
||||
if (data?.url && !data.url.startsWith("/")) {
|
||||
baseUrl = new URL(data.url).host;
|
||||
} else {
|
||||
baseUrl =
|
||||
(import.meta.env.VITE_BACKEND_BASE_URL as string | undefined) ||
|
||||
window?.location.host;
|
||||
}
|
||||
|
||||
// Subscribe to the conversation
|
||||
subscribeToConversation({
|
||||
conversationId: data.conversation_id,
|
||||
sessionApiKey: data.session_api_key,
|
||||
providersSet: providers,
|
||||
baseUrl,
|
||||
onEvent: onEventCallback,
|
||||
});
|
||||
|
||||
// Call the success callback if provided
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback(data.conversation_id);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[createConversation, subscribeToConversation, providers],
|
||||
);
|
||||
|
||||
return {
|
||||
createConversationAndSubscribe,
|
||||
unsubscribeFromConversation,
|
||||
isSubscribedToConversation,
|
||||
activeConversationIds,
|
||||
isPending,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,26 @@
|
||||
// this file generate by script, don't modify it manually!!!
|
||||
export enum I18nKey {
|
||||
MICROAGENT$NO_REPOSITORY_FOUND = "MICROAGENT$NO_REPOSITORY_FOUND",
|
||||
MICROAGENT$ADD_TO_MICROAGENT = "MICROAGENT$ADD_TO_MICROAGENT",
|
||||
MICROAGENT$WHAT_TO_ADD = "MICROAGENT$WHAT_TO_ADD",
|
||||
MICROAGENT$WHERE_TO_PUT = "MICROAGENT$WHERE_TO_PUT",
|
||||
MICROAGENT$ADD_TRIGGER = "MICROAGENT$ADD_TRIGGER",
|
||||
MICROAGENT$WHAT_TO_REMEMBER = "MICROAGENT$WHAT_TO_REMEMBER",
|
||||
MICROAGENT$ADD_TRIGGERS = "MICROAGENT$ADD_TRIGGERS",
|
||||
MICROAGENT$WAIT_FOR_RUNTIME = "MICROAGENT$WAIT_FOR_RUNTIME",
|
||||
MICROAGENT$ADDING_CONTEXT = "MICROAGENT$ADDING_CONTEXT",
|
||||
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
|
||||
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
|
||||
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
|
||||
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
|
||||
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
|
||||
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
|
||||
MICROAGENT$DESCRIBE_WHAT_TO_ADD = "MICROAGENT$DESCRIBE_WHAT_TO_ADD",
|
||||
MICROAGENT$SELECT_FILE_OR_CUSTOM = "MICROAGENT$SELECT_FILE_OR_CUSTOM",
|
||||
MICROAGENT$TYPE_TRIGGER_SPACE = "MICROAGENT$TYPE_TRIGGER_SPACE",
|
||||
MICROAGENT$LOADING_PROMPT = "MICROAGENT$LOADING_PROMPT",
|
||||
MICROAGENT$CANCEL = "MICROAGENT$CANCEL",
|
||||
MICROAGENT$LAUNCH = "MICROAGENT$LAUNCH",
|
||||
STATUS$WEBSOCKET_CLOSED = "STATUS$WEBSOCKET_CLOSED",
|
||||
HOME$LAUNCH_FROM_SCRATCH = "HOME$LAUNCH_FROM_SCRATCH",
|
||||
HOME$READ_THIS = "HOME$READ_THIS",
|
||||
@@ -97,6 +118,9 @@ export enum I18nKey {
|
||||
BROWSER$EMPTY_MESSAGE = "BROWSER$EMPTY_MESSAGE",
|
||||
SETTINGS$TITLE = "SETTINGS$TITLE",
|
||||
CONVERSATION$START_NEW = "CONVERSATION$START_NEW",
|
||||
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
|
||||
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
|
||||
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
|
||||
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
|
||||
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
@@ -259,6 +283,7 @@ export enum I18nKey {
|
||||
CHAT_INTERFACE$AGENT_RUNNING_MESSAGE = "CHAT_INTERFACE$AGENT_RUNNING_MESSAGE",
|
||||
CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE = "CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE",
|
||||
CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE = "CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE",
|
||||
CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE = "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE",
|
||||
CHAT_INTERFACE$AGENT_PAUSED_MESSAGE = "CHAT_INTERFACE$AGENT_PAUSED_MESSAGE",
|
||||
LANDING$TITLE = "LANDING$TITLE",
|
||||
LANDING$SUBTITLE = "LANDING$SUBTITLE",
|
||||
@@ -290,6 +315,10 @@ export enum I18nKey {
|
||||
USER$ACCOUNT_SETTINGS = "USER$ACCOUNT_SETTINGS",
|
||||
JUPYTER$OUTPUT_LABEL = "JUPYTER$OUTPUT_LABEL",
|
||||
BUTTON$STOP = "BUTTON$STOP",
|
||||
BUTTON$EDIT_TITLE = "BUTTON$EDIT_TITLE",
|
||||
BUTTON$DOWNLOAD_VIA_VSCODE = "BUTTON$DOWNLOAD_VIA_VSCODE",
|
||||
BUTTON$DISPLAY_COST = "BUTTON$DISPLAY_COST",
|
||||
BUTTON$SHOW_AGENT_TOOLS_AND_METADATA = "BUTTON$SHOW_AGENT_TOOLS_AND_METADATA",
|
||||
LANDING$ATTACH_IMAGES = "LANDING$ATTACH_IMAGES",
|
||||
LANDING$OPEN_REPO = "LANDING$OPEN_REPO",
|
||||
LANDING$REPLAY = "LANDING$REPLAY",
|
||||
@@ -357,6 +386,7 @@ export enum I18nKey {
|
||||
SETTINGS$DELETE_API_KEY_CONFIRMATION = "SETTINGS$DELETE_API_KEY_CONFIRMATION",
|
||||
SETTINGS$NO_API_KEYS = "SETTINGS$NO_API_KEYS",
|
||||
SETTINGS$NAME = "SETTINGS$NAME",
|
||||
SECRETS$DESCRIPTION = "SECRETS$DESCRIPTION",
|
||||
SETTINGS$KEY_PREFIX = "SETTINGS$KEY_PREFIX",
|
||||
SETTINGS$CREATED_AT = "SETTINGS$CREATED_AT",
|
||||
SETTINGS$LAST_USED = "SETTINGS$LAST_USED",
|
||||
@@ -565,6 +595,7 @@ export enum I18nKey {
|
||||
CONVERSATION$NO_MICROAGENTS = "CONVERSATION$NO_MICROAGENTS",
|
||||
CONVERSATION$FAILED_TO_FETCH_MICROAGENTS = "CONVERSATION$FAILED_TO_FETCH_MICROAGENTS",
|
||||
MICROAGENTS_MODAL$TITLE = "MICROAGENTS_MODAL$TITLE",
|
||||
MICROAGENTS_MODAL$WARNING = "MICROAGENTS_MODAL$WARNING",
|
||||
MICROAGENTS_MODAL$TRIGGERS = "MICROAGENTS_MODAL$TRIGGERS",
|
||||
MICROAGENTS_MODAL$INPUTS = "MICROAGENTS_MODAL$INPUTS",
|
||||
MICROAGENTS_MODAL$TOOLS = "MICROAGENTS_MODAL$TOOLS",
|
||||
@@ -633,4 +664,13 @@ export enum I18nKey {
|
||||
API$TAVILY_KEY_EXAMPLE = "API$TAVILY_KEY_EXAMPLE",
|
||||
API$TVLY_KEY_EXAMPLE = "API$TVLY_KEY_EXAMPLE",
|
||||
SECRETS$CONNECT_GIT_PROVIDER = "SECRETS$CONNECT_GIT_PROVIDER",
|
||||
CONVERSATION$BUDGET_USAGE_FORMAT = "CONVERSATION$BUDGET_USAGE_FORMAT",
|
||||
CONVERSATION$CACHE_HIT = "CONVERSATION$CACHE_HIT",
|
||||
CONVERSATION$CACHE_WRITE = "CONVERSATION$CACHE_WRITE",
|
||||
FEEDBACK$STAR_RATING = "FEEDBACK$STAR_RATING",
|
||||
BUTTON$CONFIRM = "BUTTON$CONFIRM",
|
||||
FORM$VALUE = "FORM$VALUE",
|
||||
FORM$DESCRIPTION = "FORM$DESCRIPTION",
|
||||
COMMON$OPTIONAL = "COMMON$OPTIONAL",
|
||||
BROWSER$SERVER_MESSAGE = "BROWSER$SERVER_MESSAGE",
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ i18n
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
debug: import.meta.env.NODE_ENV === "development",
|
||||
load: "languageOnly",
|
||||
load: "currentOnly",
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
|
||||
@@ -1,4 +1,341 @@
|
||||
{
|
||||
"MICROAGENT$NO_REPOSITORY_FOUND": {
|
||||
"en": "No repository found to launch microagent",
|
||||
"ja": "マイクロエージェントを起動するためのリポジトリが見つかりません",
|
||||
"zh-CN": "未找到启动微代理的存储库",
|
||||
"zh-TW": "未找到啟動微代理的存儲庫",
|
||||
"ko-KR": "마이크로에이전트를 시작할 저장소를 찾을 수 없습니다",
|
||||
"no": "Ingen repository funnet for å starte mikroagent",
|
||||
"it": "Nessun repository trovato per avviare il microagente",
|
||||
"pt": "Nenhum repositório encontrado para iniciar o microagente",
|
||||
"es": "No se encontró ningún repositorio para iniciar el microagente",
|
||||
"ar": "لم يتم العثور على مستودع لإطلاق الوكيل المصغر",
|
||||
"fr": "Aucun dépôt trouvé pour lancer le micro-agent",
|
||||
"tr": "Mikro ajanı başlatmak için depo bulunamadı",
|
||||
"de": "Kein Repository gefunden, um Microagent zu starten",
|
||||
"uk": "Не знайдено репозиторій для запуску мікроагента"
|
||||
},
|
||||
|
||||
"MICROAGENT$ADD_TO_MICROAGENT": {
|
||||
"en": "Add to Microagent",
|
||||
"ja": "マイクロエージェントに追加",
|
||||
"zh-CN": "添加到微代理",
|
||||
"zh-TW": "添加到微代理",
|
||||
"ko-KR": "마이크로에이전트에 추가",
|
||||
"no": "Legg til i mikroagent",
|
||||
"it": "Aggiungi al microagente",
|
||||
"pt": "Adicionar ao microagente",
|
||||
"es": "Añadir al microagente",
|
||||
"ar": "إضافة إلى الوكيل المصغر",
|
||||
"fr": "Ajouter au micro-agent",
|
||||
"tr": "Mikro ajana ekle",
|
||||
"de": "Zum Microagent hinzufügen",
|
||||
"uk": "Додати до мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_ADD": {
|
||||
"en": "What would you like to add to the Microagent?",
|
||||
"ja": "マイクロエージェントに何を追加しますか?",
|
||||
"zh-CN": "您想添加什么到微代理?",
|
||||
"zh-TW": "您想添加什麼到微代理?",
|
||||
"ko-KR": "마이크로에이전트에 무엇을 추가하시겠습니까?",
|
||||
"no": "Hva vil du legge til i mikroagenten?",
|
||||
"it": "Cosa vorresti aggiungere al microagente?",
|
||||
"pt": "O que você gostaria de adicionar ao microagente?",
|
||||
"es": "¿Qué te gustaría añadir al microagente?",
|
||||
"ar": "ماذا تريد أن تضيف إلى الوكيل المصغر؟",
|
||||
"fr": "Que souhaitez-vous ajouter au micro-agent ?",
|
||||
"tr": "Mikro ajana ne eklemek istersiniz?",
|
||||
"de": "Was möchten Sie zum Microagent hinzufügen?",
|
||||
"uk": "Що ви хочете додати до мікроагента?"
|
||||
},
|
||||
"MICROAGENT$WHERE_TO_PUT": {
|
||||
"en": "Where should we put it?",
|
||||
"ja": "どこに配置しますか?",
|
||||
"zh-CN": "我们应该把它放在哪里?",
|
||||
"zh-TW": "我們應該把它放在哪裡?",
|
||||
"ko-KR": "어디에 넣을까요?",
|
||||
"no": "Hvor skal vi plassere det?",
|
||||
"it": "Dove dovremmo metterlo?",
|
||||
"pt": "Onde devemos colocá-lo?",
|
||||
"es": "¿Dónde deberíamos ponerlo?",
|
||||
"ar": "أين يجب أن نضعه؟",
|
||||
"fr": "Où devons-nous le mettre ?",
|
||||
"tr": "Nereye koyalım?",
|
||||
"de": "Wo sollen wir es platzieren?",
|
||||
"uk": "Куди ми повинні його помістити?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGER": {
|
||||
"en": "Add a trigger for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til en utløser for mikroagenten",
|
||||
"it": "Aggiungi un trigger per il microagente",
|
||||
"pt": "Adicionar um gatilho para o microagente",
|
||||
"es": "Añadir un disparador para el microagente",
|
||||
"ar": "إضافة مشغل للوكيل المصغر",
|
||||
"fr": "Ajouter un déclencheur pour le micro-agent",
|
||||
"tr": "Mikro ajan için bir tetikleyici ekleyin",
|
||||
"de": "Fügen Sie einen Auslöser für den Microagent hinzu",
|
||||
"uk": "Додати тригер для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WHAT_TO_REMEMBER": {
|
||||
"en": "What would you like your microagent to remember?",
|
||||
"ja": "マイクロエージェントに何を覚えさせたいですか?",
|
||||
"zh-CN": "您希望您的微代理记住什么?",
|
||||
"zh-TW": "您希望您的微代理記住什麼?",
|
||||
"ko-KR": "마이크로에이전트가 무엇을 기억하기를 원하시나요?",
|
||||
"no": "Hva vil du at mikroagenten din skal huske?",
|
||||
"it": "Cosa vorresti che il tuo microagente ricordasse?",
|
||||
"pt": "O que você gostaria que seu microagente lembrasse?",
|
||||
"es": "¿Qué te gustaría que tu microagente recordara?",
|
||||
"ar": "ماذا تريد أن يتذكر وكيلك المصغر؟",
|
||||
"fr": "Que souhaitez-vous que votre micro-agent se souvienne ?",
|
||||
"tr": "Mikro ajanınızın neyi hatırlamasını istersiniz?",
|
||||
"de": "Was soll sich Ihr Microagent merken?",
|
||||
"uk": "Що ви хочете, щоб ваш мікроагент запам'ятав?"
|
||||
},
|
||||
"MICROAGENT$ADD_TRIGGERS": {
|
||||
"en": "Add triggers for the microagent",
|
||||
"ja": "マイクロエージェントのトリガーを追加",
|
||||
"zh-CN": "为微代理添加触发器",
|
||||
"zh-TW": "為微代理添加觸發器",
|
||||
"ko-KR": "마이크로에이전트의 트리거 추가",
|
||||
"no": "Legg til utløsere for mikroagenten",
|
||||
"it": "Aggiungi trigger per il microagente",
|
||||
"pt": "Adicionar gatilhos para o microagente",
|
||||
"es": "Añadir disparadores para el microagente",
|
||||
"ar": "إضافة مشغلات للوكيل المصغر",
|
||||
"fr": "Ajouter des déclencheurs pour le micro-agent",
|
||||
"tr": "Mikro ajan için tetikleyiciler ekleyin",
|
||||
"de": "Auslöser für den Microagent hinzufügen",
|
||||
"uk": "Додати тригери для мікроагента"
|
||||
},
|
||||
"MICROAGENT$WAIT_FOR_RUNTIME": {
|
||||
"en": "Please wait for the runtime to be active.",
|
||||
"ja": "ランタイムがアクティブになるまでお待ちください。",
|
||||
"zh-CN": "请等待运行时激活。",
|
||||
"zh-TW": "請等待運行時激活。",
|
||||
"ko-KR": "런타임이 활성화될 때까지 기다려주세요.",
|
||||
"no": "Vennligst vent til kjøretidsmiljøet er aktivt.",
|
||||
"it": "Attendere che il runtime sia attivo.",
|
||||
"pt": "Aguarde até que o tempo de execução esteja ativo.",
|
||||
"es": "Por favor, espere a que el tiempo de ejecución esté activo.",
|
||||
"ar": "يرجى الانتظار حتى يصبح وقت التشغيل نشطًا.",
|
||||
"fr": "Veuillez attendre que le runtime soit actif.",
|
||||
"tr": "Lütfen çalışma zamanının aktif olmasını bekleyin.",
|
||||
"de": "Bitte warten Sie, bis die Laufzeitumgebung aktiv ist.",
|
||||
"uk": "Будь ласка, зачекайте, поки середовище виконання стане активним."
|
||||
},
|
||||
"MICROAGENT$ADDING_CONTEXT": {
|
||||
"en": "OpenHands is adding this new context to your respository. We'll let you know when the pull request is ready.",
|
||||
"ja": "OpenHandsはこの新しいコンテキストをあなたのリポジトリに追加しています。プルリクエストの準備ができたらお知らせします。",
|
||||
"zh-CN": "OpenHands正在将此新上下文添加到您的存储库中。拉取请求准备好后,我们会通知您。",
|
||||
"zh-TW": "OpenHands正在將此新上下文添加到您的存儲庫中。拉取請求準備好後,我們會通知您。",
|
||||
"ko-KR": "OpenHands가 이 새로운 컨텍스트를 저장소에 추가하고 있습니다. 풀 리퀘스트가 준비되면 알려드리겠습니다.",
|
||||
"no": "OpenHands legger til denne nye konteksten i ditt repository. Vi gir deg beskjed når pull-forespørselen er klar.",
|
||||
"it": "OpenHands sta aggiungendo questo nuovo contesto al tuo repository. Ti faremo sapere quando la pull request sarà pronta.",
|
||||
"pt": "OpenHands está adicionando este novo contexto ao seu repositório. Avisaremos quando o pull request estiver pronto.",
|
||||
"es": "OpenHands está añadiendo este nuevo contexto a tu repositorio. Te avisaremos cuando la solicitud de extracción esté lista.",
|
||||
"ar": "يقوم OpenHands بإضافة هذا السياق الجديد إلى مستودعك. سنعلمك عندما يكون طلب السحب جاهزًا.",
|
||||
"fr": "OpenHands ajoute ce nouveau contexte à votre dépôt. Nous vous informerons lorsque la pull request sera prête.",
|
||||
"tr": "OpenHands bu yeni bağlamı deponuza ekliyor. Çekme isteği hazır olduğunda size haber vereceğiz.",
|
||||
"de": "OpenHands fügt diesen neuen Kontext zu Ihrem Repository hinzu. Wir informieren Sie, wenn der Pull Request bereit ist.",
|
||||
"uk": "OpenHands додає цей новий контекст до вашого репозиторію. Ми повідомимо вас, коли запит на витягування буде готовий."
|
||||
},
|
||||
"MICROAGENT$VIEW_CONVERSATION": {
|
||||
"en": "View Conversation",
|
||||
"ja": "会話を表示",
|
||||
"zh-CN": "查看对话",
|
||||
"zh-TW": "查看對話",
|
||||
"ko-KR": "대화 보기",
|
||||
"no": "Vis samtale",
|
||||
"it": "Visualizza conversazione",
|
||||
"pt": "Ver conversa",
|
||||
"es": "Ver conversación",
|
||||
"ar": "عرض المحادثة",
|
||||
"fr": "Voir la conversation",
|
||||
"tr": "Konuşmayı Görüntüle",
|
||||
"de": "Konversation anzeigen",
|
||||
"uk": "Переглянути розмову"
|
||||
},
|
||||
"MICROAGENT$SUCCESS_PR_READY": {
|
||||
"en": "Success! Your microagent pull request is ready.",
|
||||
"ja": "成功!マイクロエージェントのプルリクエストの準備ができました。",
|
||||
"zh-CN": "成功!您的微代理拉取请求已准备就绪。",
|
||||
"zh-TW": "成功!您的微代理拉取請求已準備就緒。",
|
||||
"ko-KR": "성공! 마이크로에이전트 풀 리퀘스트가 준비되었습니다.",
|
||||
"no": "Suksess! Din mikroagent pull request er klar.",
|
||||
"it": "Successo! La tua pull request del microagente è pronta.",
|
||||
"pt": "Sucesso! Seu pull request de microagente está pronto.",
|
||||
"es": "¡Éxito! Tu solicitud de extracción de microagente está lista.",
|
||||
"ar": "نجاح! طلب سحب الوكيل المصغر الخاص بك جاهز.",
|
||||
"fr": "Succès ! Votre pull request de micro-agent est prête.",
|
||||
"tr": "Başarılı! Mikro ajan çekme isteğiniz hazır.",
|
||||
"de": "Erfolg! Ihr Microagent Pull Request ist bereit.",
|
||||
"uk": "Успіх! Ваш запит на витягування мікроагента готовий."
|
||||
},
|
||||
"MICROAGENT$STATUS_CREATING": {
|
||||
"en": "Modifying microagent...",
|
||||
"ja": "マイクロエージェントを変更中...",
|
||||
"zh-CN": "正在修改微代理...",
|
||||
"zh-TW": "正在修改微代理...",
|
||||
"ko-KR": "마이크로에이전트 수정 중...",
|
||||
"no": "Endrer mikroagent...",
|
||||
"it": "Modifica del microagente in corso...",
|
||||
"pt": "Modificando microagente...",
|
||||
"es": "Modificando microagente...",
|
||||
"ar": "تعديل الوكيل المصغر...",
|
||||
"fr": "Modification du micro-agent en cours...",
|
||||
"tr": "Mikro ajan değiştiriliyor...",
|
||||
"de": "Microagent wird geändert...",
|
||||
"uk": "Зміна мікроагента..."
|
||||
},
|
||||
"MICROAGENT$STATUS_COMPLETED": {
|
||||
"en": "View microagent update",
|
||||
"ja": "マイクロエージェントの更新を表示",
|
||||
"zh-CN": "查看微代理更新",
|
||||
"zh-TW": "查看微代理更新",
|
||||
"ko-KR": "마이크로에이전트 업데이트 보기",
|
||||
"no": "Vis mikroagent oppdatering",
|
||||
"it": "Visualizza aggiornamento microagente",
|
||||
"pt": "Ver atualização do microagente",
|
||||
"es": "Ver actualización del microagente",
|
||||
"ar": "عرض تحديث الوكيل المصغر",
|
||||
"fr": "Voir la mise à jour du micro-agent",
|
||||
"tr": "Mikro ajan güncellemesini görüntüle",
|
||||
"de": "Microagent-Update anzeigen",
|
||||
"uk": "Переглянути оновлення мікроагента"
|
||||
},
|
||||
"MICROAGENT$STATUS_ERROR": {
|
||||
"en": "Microagent encountered an error",
|
||||
"ja": "マイクロエージェントでエラーが発生しました",
|
||||
"zh-CN": "微代理遇到错误",
|
||||
"zh-TW": "微代理遇到錯誤",
|
||||
"ko-KR": "마이크로에이전트에서 오류가 발생했습니다",
|
||||
"no": "Mikroagent støtte på en feil",
|
||||
"it": "Il microagente ha riscontrato un errore",
|
||||
"pt": "Microagente encontrou um erro",
|
||||
"es": "El microagente encontró un error",
|
||||
"ar": "واجه الوكيل المصغر خطأ",
|
||||
"fr": "Le micro-agent a rencontré une erreur",
|
||||
"tr": "Mikro ajan bir hatayla karşılaştı",
|
||||
"de": "Microagent ist auf einen Fehler gestoßen",
|
||||
"uk": "Мікроагент зіткнувся з помилкою"
|
||||
},
|
||||
"MICROAGENT$VIEW_YOUR_PR": {
|
||||
"en": "View your PR",
|
||||
"ja": "PRを表示",
|
||||
"zh-CN": "查看您的PR",
|
||||
"zh-TW": "查看您的PR",
|
||||
"ko-KR": "PR 보기",
|
||||
"no": "Se din PR",
|
||||
"it": "Visualizza la tua PR",
|
||||
"pt": "Ver seu PR",
|
||||
"es": "Ver tu PR",
|
||||
"ar": "عرض طلب السحب الخاص بك",
|
||||
"fr": "Voir votre PR",
|
||||
"tr": "PR'ınızı görüntüleyin",
|
||||
"de": "Ihre PR anzeigen",
|
||||
"uk": "Переглянути ваш PR"
|
||||
},
|
||||
"MICROAGENT$DESCRIBE_WHAT_TO_ADD": {
|
||||
"en": "Describe what you want to add to the Microagent...",
|
||||
"ja": "マイクロエージェントに追加したい内容を説明してください...",
|
||||
"zh-CN": "描述您想添加到微代理的内容...",
|
||||
"zh-TW": "描述您想添加到微代理的內容...",
|
||||
"ko-KR": "마이크로에이전트에 추가하고 싶은 내용을 설명하세요...",
|
||||
"no": "Beskriv hva du vil legge til i mikroagenten...",
|
||||
"it": "Descrivi cosa vuoi aggiungere al microagente...",
|
||||
"pt": "Descreva o que você deseja adicionar ao microagente...",
|
||||
"es": "Describe lo que quieres añadir al microagente...",
|
||||
"ar": "صف ما تريد إضافته إلى الوكيل المصغر...",
|
||||
"fr": "Décrivez ce que vous souhaitez ajouter au micro-agent...",
|
||||
"tr": "Mikro ajana eklemek istediğinizi açıklayın...",
|
||||
"de": "Beschreiben Sie, was Sie zum Microagent hinzufügen möchten...",
|
||||
"uk": "Опишіть, що ви хочете додати до мікроагента..."
|
||||
},
|
||||
"MICROAGENT$SELECT_FILE_OR_CUSTOM": {
|
||||
"en": "Select a microagent file or enter a custom value",
|
||||
"ja": "マイクロエージェントファイルを選択するか、カスタム値を入力してください",
|
||||
"zh-CN": "选择微代理文件或输入自定义值",
|
||||
"zh-TW": "選擇微代理文件或輸入自定義值",
|
||||
"ko-KR": "마이크로에이전트 파일을 선택하거나 사용자 지정 값을 입력하세요",
|
||||
"no": "Velg en mikroagent-fil eller skriv inn en egendefinert verdi",
|
||||
"it": "Seleziona un file microagente o inserisci un valore personalizzato",
|
||||
"pt": "Selecione um arquivo de microagente ou insira um valor personalizado",
|
||||
"es": "Selecciona un archivo de microagente o introduce un valor personalizado",
|
||||
"ar": "حدد ملف وكيل مصغر أو أدخل قيمة مخصصة",
|
||||
"fr": "Sélectionnez un fichier micro-agent ou entrez une valeur personnalisée",
|
||||
"tr": "Bir mikro ajan dosyası seçin veya özel bir değer girin",
|
||||
"de": "Wählen Sie eine Microagent-Datei aus oder geben Sie einen benutzerdefinierten Wert ein",
|
||||
"uk": "Виберіть файл мікроагента або введіть власне значення"
|
||||
},
|
||||
"MICROAGENT$TYPE_TRIGGER_SPACE": {
|
||||
"en": "Type a trigger and press Space to add it",
|
||||
"ja": "トリガーを入力し、スペースキーを押して追加してください",
|
||||
"zh-CN": "输入触发器并按空格键添加",
|
||||
"zh-TW": "輸入觸發器並按空格鍵添加",
|
||||
"ko-KR": "트리거를 입력하고 스페이스바를 눌러 추가하세요",
|
||||
"no": "Skriv inn en utløser og trykk mellomrom for å legge den til",
|
||||
"it": "Digita un trigger e premi Spazio per aggiungerlo",
|
||||
"pt": "Digite um gatilho e pressione Espaço para adicioná-lo",
|
||||
"es": "Escribe un disparador y pulsa Espacio para añadirlo",
|
||||
"ar": "اكتب مشغلًا واضغط على المسافة لإضافته",
|
||||
"fr": "Tapez un déclencheur et appuyez sur Espace pour l'ajouter",
|
||||
"tr": "Bir tetikleyici yazın ve eklemek için Boşluk tuşuna basın",
|
||||
"de": "Geben Sie einen Auslöser ein und drücken Sie die Leertaste, um ihn hinzuzufügen",
|
||||
"uk": "Введіть тригер і натисніть пробіл, щоб додати його"
|
||||
},
|
||||
"MICROAGENT$LOADING_PROMPT": {
|
||||
"en": "Loading prompt...",
|
||||
"ja": "プロンプトを読み込み中...",
|
||||
"zh-CN": "加载提示中...",
|
||||
"zh-TW": "加載提示中...",
|
||||
"ko-KR": "프롬프트 로딩 중...",
|
||||
"no": "Laster inn prompt...",
|
||||
"it": "Caricamento prompt...",
|
||||
"pt": "Carregando prompt...",
|
||||
"es": "Cargando prompt...",
|
||||
"ar": "جاري تحميل المطالبة...",
|
||||
"fr": "Chargement du prompt...",
|
||||
"tr": "İstem yükleniyor...",
|
||||
"de": "Prompt wird geladen...",
|
||||
"uk": "Завантаження підказки..."
|
||||
},
|
||||
"MICROAGENT$CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"ar": "إلغاء",
|
||||
"fr": "Annuler",
|
||||
"tr": "İptal",
|
||||
"de": "Abbrechen",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"MICROAGENT$LAUNCH": {
|
||||
"en": "Launch",
|
||||
"ja": "起動",
|
||||
"zh-CN": "启动",
|
||||
"zh-TW": "啟動",
|
||||
"ko-KR": "시작",
|
||||
"no": "Start",
|
||||
"it": "Avvia",
|
||||
"pt": "Iniciar",
|
||||
"es": "Iniciar",
|
||||
"ar": "إطلاق",
|
||||
"fr": "Lancer",
|
||||
"tr": "Başlat",
|
||||
"de": "Starten",
|
||||
"uk": "Запустити"
|
||||
},
|
||||
"STATUS$WEBSOCKET_CLOSED": {
|
||||
"en": "The WebSocket connection was closed.",
|
||||
"ja": "WebSocket接続が閉じられました。",
|
||||
@@ -1551,6 +1888,54 @@
|
||||
"de": "Neue Unterhaltung starten",
|
||||
"uk": "Почати нову розмову"
|
||||
},
|
||||
"CONVERSATION$REPOSITORY": {
|
||||
"en": "Repository",
|
||||
"ja": "リポジトリ",
|
||||
"zh-CN": "仓库",
|
||||
"zh-TW": "倉庫",
|
||||
"ko-KR": "저장소",
|
||||
"no": "Repository",
|
||||
"it": "Repository",
|
||||
"pt": "Repositório",
|
||||
"es": "Repositorio",
|
||||
"ar": "المستودع",
|
||||
"fr": "Dépôt",
|
||||
"tr": "Depo",
|
||||
"de": "Repository",
|
||||
"uk": "Репозиторій"
|
||||
},
|
||||
"CONVERSATION$BRANCH": {
|
||||
"en": "Branch",
|
||||
"ja": "ブランチ",
|
||||
"zh-CN": "分支",
|
||||
"zh-TW": "分支",
|
||||
"ko-KR": "브랜치",
|
||||
"no": "Gren",
|
||||
"it": "Ramo",
|
||||
"pt": "Ramo",
|
||||
"es": "Rama",
|
||||
"ar": "الفرع",
|
||||
"fr": "Branche",
|
||||
"tr": "Dal",
|
||||
"de": "Zweig",
|
||||
"uk": "Гілка"
|
||||
},
|
||||
"CONVERSATION$GIT_PROVIDER": {
|
||||
"en": "Git Provider",
|
||||
"ja": "Git プロバイダー",
|
||||
"zh-CN": "Git 提供商",
|
||||
"zh-TW": "Git 提供商",
|
||||
"ko-KR": "Git 제공업체",
|
||||
"no": "Git-leverandør",
|
||||
"it": "Provider Git",
|
||||
"pt": "Provedor Git",
|
||||
"es": "Proveedor Git",
|
||||
"ar": "مزود Git",
|
||||
"fr": "Fournisseur Git",
|
||||
"tr": "Git Sağlayıcısı",
|
||||
"de": "Git-Anbieter",
|
||||
"uk": "Git-провайдер"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$TITLE": {
|
||||
"en": "Account Settings",
|
||||
"ja": "アカウント設定",
|
||||
@@ -4064,7 +4449,7 @@
|
||||
"uk": "Зупинено"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Initializing Agent...",
|
||||
"en": "Initializing agent...",
|
||||
"de": "Agent wird initialisiert...",
|
||||
"zh-CN": "正在初始化智能体...",
|
||||
"zh-TW": "正在初始化智能體...",
|
||||
@@ -4128,20 +4513,36 @@
|
||||
"uk": "Агент очікує на введення даних від користувача..."
|
||||
},
|
||||
"CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE": {
|
||||
"en": "Agent is Rate Limited",
|
||||
"zh-CN": "智能体已达到速率限制",
|
||||
"zh-TW": "智慧代理已達到速率限制",
|
||||
"de": "Agent ist ratenbegrenzt",
|
||||
"ko-KR": "에이전트가 속도 제한되었습니다",
|
||||
"no": "Agenten er hastighetsbegrenset",
|
||||
"it": "L'agente è limitato dalla frequenza",
|
||||
"pt": "O agente está com limite de taxa",
|
||||
"es": "El agente está limitado por tasa",
|
||||
"ar": "الوكيل مقيد بحد السرعة",
|
||||
"fr": "L'agent est limité en fréquence",
|
||||
"tr": "Ajan hız sınırına ulaştı",
|
||||
"ja": "エージェントがレート制限中",
|
||||
"uk": "Агента обмежено кількістю запитів"
|
||||
"en": "Agent is Rate Limited. Retrying...",
|
||||
"zh-CN": "智能体已达到速率限制。正在重试...",
|
||||
"zh-TW": "智慧代理已達到速率限制。正在重試...",
|
||||
"de": "Agent ist ratenbegrenzt. Wiederholungsversuch...",
|
||||
"ko-KR": "에이전트가 속도 제한되었습니다. 재시도 중...",
|
||||
"no": "Agenten er hastighetsbegrenset. Prøver på nytt...",
|
||||
"it": "L'agente è limitato dalla frequenza. Riprovando...",
|
||||
"pt": "O agente está com limite de taxa. Tentando novamente...",
|
||||
"es": "El agente está limitado por tasa. Reintentando...",
|
||||
"ar": "الوكيل مقيد بحد السرعة. إعادة المحاولة...",
|
||||
"fr": "L'agent est limité en fréquence. Nouvelle tentative...",
|
||||
"tr": "Ajan hız sınırına ulaştı. Yeniden deniyor...",
|
||||
"ja": "エージェントがレート制限中。再試行しています...",
|
||||
"uk": "Агента обмежено кількістю запитів. Повторюємо спробу..."
|
||||
},
|
||||
"CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE": {
|
||||
"en": "Agent is rate-limited. Stopped.",
|
||||
"zh-CN": "智能体已达到速率限制。已停止。",
|
||||
"zh-TW": "智慧代理已達到速率限制。已停止。",
|
||||
"de": "Agent ist ratenbegrenzt. Angehalten.",
|
||||
"ko-KR": "에이전트가 속도 제한되었습니다. 중지됨.",
|
||||
"no": "Agenten er hastighetsbegrenset. Stoppet.",
|
||||
"it": "L'agente è limitato dalla frequenza. Fermato.",
|
||||
"pt": "O agente está com limite de taxa. Parado.",
|
||||
"es": "El agente está limitado por tasa. Detenido.",
|
||||
"ar": "الوكيل مقيد بحد السرعة. توقف.",
|
||||
"fr": "L'agent est limité en fréquence. Arrêté.",
|
||||
"tr": "Ajan hız sınırına ulaştı. Durduruldu.",
|
||||
"ja": "エージェントがレート制限中。停止しました。",
|
||||
"uk": "Агента обмежено кількістю запитів. Зупинено."
|
||||
},
|
||||
"CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": {
|
||||
"en": "Agent has paused.",
|
||||
@@ -4639,6 +5040,70 @@
|
||||
"tr": "Durdur",
|
||||
"uk": "Стоп"
|
||||
},
|
||||
"BUTTON$EDIT_TITLE": {
|
||||
"en": "Edit Title",
|
||||
"ja": "タイトルを編集",
|
||||
"zh-CN": "编辑标题",
|
||||
"zh-TW": "編輯標題",
|
||||
"ko-KR": "제목 편집",
|
||||
"fr": "Modifier le titre",
|
||||
"es": "Editar título",
|
||||
"de": "Titel bearbeiten",
|
||||
"it": "Modifica titolo",
|
||||
"pt": "Editar título",
|
||||
"ar": "تحرير العنوان",
|
||||
"no": "Rediger tittel",
|
||||
"tr": "Başlığı Düzenle",
|
||||
"uk": "Редагувати заголовок"
|
||||
},
|
||||
"BUTTON$DOWNLOAD_VIA_VSCODE": {
|
||||
"en": "Download via VS Code",
|
||||
"ja": "VS Code経由でダウンロード",
|
||||
"zh-CN": "通过VS Code下载",
|
||||
"zh-TW": "透過VS Code下載",
|
||||
"ko-KR": "VS Code를 통해 다운로드",
|
||||
"fr": "Télécharger via VS Code",
|
||||
"es": "Descargar a través de VS Code",
|
||||
"de": "Über VS Code herunterladen",
|
||||
"it": "Scarica tramite VS Code",
|
||||
"pt": "Baixar via VS Code",
|
||||
"ar": "تحميل عبر VS Code",
|
||||
"no": "Last ned via VS Code",
|
||||
"tr": "VS Code ile İndir",
|
||||
"uk": "Завантажити через VS Code"
|
||||
},
|
||||
"BUTTON$DISPLAY_COST": {
|
||||
"en": "Display Cost",
|
||||
"ja": "コストを表示",
|
||||
"zh-CN": "显示成本",
|
||||
"zh-TW": "顯示成本",
|
||||
"ko-KR": "비용 표시",
|
||||
"fr": "Afficher le coût",
|
||||
"es": "Mostrar costo",
|
||||
"de": "Kosten anzeigen",
|
||||
"it": "Mostra costo",
|
||||
"pt": "Mostrar custo",
|
||||
"ar": "عرض التكلفة",
|
||||
"no": "Vis kostnad",
|
||||
"tr": "Maliyeti Göster",
|
||||
"uk": "Показати вартість"
|
||||
},
|
||||
"BUTTON$SHOW_AGENT_TOOLS_AND_METADATA": {
|
||||
"en": "Show Agent Tools & Metadata",
|
||||
"ja": "エージェントツールとメタデータを表示",
|
||||
"zh-CN": "显示代理工具和元数据",
|
||||
"zh-TW": "顯示代理工具和元數據",
|
||||
"ko-KR": "에이전트 도구 및 메타데이터 표시",
|
||||
"fr": "Afficher les outils et métadonnées de l'agent",
|
||||
"es": "Mostrar herramientas y metadatos del agente",
|
||||
"de": "Agent-Tools und Metadaten anzeigen",
|
||||
"it": "Mostra strumenti e metadati dell'agente",
|
||||
"pt": "Mostrar ferramentas e metadados do agente",
|
||||
"ar": "عرض أدوات الوكيل والبيانات الوصفية",
|
||||
"no": "Vis agentverktøy og metadata",
|
||||
"tr": "Ajan Araçları ve Meta Verileri Göster",
|
||||
"uk": "Показати інструменти агента та метадані"
|
||||
},
|
||||
"LANDING$ATTACH_IMAGES": {
|
||||
"en": "Attach images",
|
||||
"ja": "画像を添付",
|
||||
@@ -5711,6 +6176,22 @@
|
||||
"es": "Nombre",
|
||||
"tr": "İsim"
|
||||
},
|
||||
"SECRETS$DESCRIPTION": {
|
||||
"en": "Description",
|
||||
"uk": "Опис",
|
||||
"ja": "説明",
|
||||
"zh-CN": "描述",
|
||||
"zh-TW": "描述",
|
||||
"ko-KR": "설명",
|
||||
"no": "Beskrivelse",
|
||||
"ar": "الوصف",
|
||||
"de": "Beschreibung",
|
||||
"fr": "Description",
|
||||
"it": "Descrizione",
|
||||
"pt": "Descrição",
|
||||
"es": "Descripción",
|
||||
"tr": "Açıklama"
|
||||
},
|
||||
"SETTINGS$KEY_PREFIX": {
|
||||
"en": "Key Prefix",
|
||||
"uk": "Префікс ключа",
|
||||
@@ -9039,6 +9520,22 @@
|
||||
"tr": "Kullanılabilir mikro ajanlar",
|
||||
"uk": "Доступні мікроагенти"
|
||||
},
|
||||
"MICROAGENTS_MODAL$WARNING": {
|
||||
"en": "If you update the microagents, you will need to stop the conversation and then click on the refresh button to see the changes.",
|
||||
"ja": "マイクロエージェントを更新する場合、会話を停止してから更新ボタンをクリックして変更を確認する必要があります。",
|
||||
"zh-CN": "如果您更新微代理,您需要停止对话,然后点击刷新按钮以查看更改。",
|
||||
"zh-TW": "如果您更新微代理,您需要停止對話,然後點擊重新整理按鈕以查看更改。",
|
||||
"ko-KR": "마이크로에이전트를 업데이트하는 경우 대화를 중지한 후 새로고침 버튼을 클릭하여 변경사항을 확인해야 합니다.",
|
||||
"no": "Hvis du oppdaterer mikroagentene, må du stoppe samtalen og deretter klikke på oppdater-knappen for å se endringene.",
|
||||
"ar": "إذا قمت بتحديث الوكلاء المصغرين، فستحتاج إلى إيقاف المحادثة ثم النقر على زر التحديث لرؤية التغييرات.",
|
||||
"de": "Wenn Sie die Mikroagenten aktualisieren, müssen Sie das Gespräch beenden und dann auf die Aktualisieren-Schaltfläche klicken, um die Änderungen zu sehen.",
|
||||
"fr": "Si vous mettez à jour les micro-agents, vous devrez arrêter la conversation puis cliquer sur le bouton actualiser pour voir les changements.",
|
||||
"it": "Se aggiorni i microagenti, dovrai fermare la conversazione e poi cliccare sul pulsante aggiorna per vedere le modifiche.",
|
||||
"pt": "Se você atualizar os microagentes, precisará parar a conversa e depois clicar no botão atualizar para ver as alterações.",
|
||||
"es": "Si actualiza los microagentes, necesitará detener la conversación y luego hacer clic en el botón actualizar para ver los cambios.",
|
||||
"tr": "Mikro ajanları güncellerseniz, konuşmayı durdurmanız ve ardından değişiklikleri görmek için yenile düğmesine tıklamanız gerekecektir.",
|
||||
"uk": "Якщо ви оновите мікроагенти, вам потрібно буде зупинити розмову, а потім натиснути кнопку оновлення, щоб побачити зміни."
|
||||
},
|
||||
"MICROAGENTS_MODAL$TRIGGERS": {
|
||||
"en": "Triggers",
|
||||
"ja": "トリガー",
|
||||
@@ -10126,5 +10623,149 @@
|
||||
"tr": "Gizli anahtarları yönetmek için bir Git sağlayıcısına bağlan",
|
||||
"de": "Git-Anbieter verbinden, um Geheimnisse zu verwalten",
|
||||
"uk": "Підключити провайдера Git для управління секретами"
|
||||
},
|
||||
"CONVERSATION$BUDGET_USAGE_FORMAT": {
|
||||
"en": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ja": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-CN": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"zh-TW": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ko-KR": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"no": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"it": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"pt": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"es": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"ar": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"fr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"tr": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"de": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})",
|
||||
"uk": "${currentCost} / ${maxBudget} ({usagePercentage}% {used})"
|
||||
},
|
||||
"CONVERSATION$CACHE_HIT": {
|
||||
"en": "Cache Hit:",
|
||||
"ja": "キャッシュヒット:",
|
||||
"zh-CN": "缓存命中:",
|
||||
"zh-TW": "快取命中:",
|
||||
"ko-KR": "캐시 히트:",
|
||||
"no": "Cache-treff:",
|
||||
"it": "Cache Hit:",
|
||||
"pt": "Cache Hit:",
|
||||
"es": "Cache Hit:",
|
||||
"ar": "إصابة التخزين المؤقت:",
|
||||
"fr": "Cache Hit:",
|
||||
"tr": "Önbellek İsabeti:",
|
||||
"de": "Cache-Treffer:",
|
||||
"uk": "Попадання в кеш:"
|
||||
},
|
||||
"CONVERSATION$CACHE_WRITE": {
|
||||
"en": "Cache Write:",
|
||||
"ja": "キャッシュ書き込み:",
|
||||
"zh-CN": "缓存写入:",
|
||||
"zh-TW": "快取寫入:",
|
||||
"ko-KR": "캐시 쓰기:",
|
||||
"no": "Cache-skriving:",
|
||||
"it": "Cache Write:",
|
||||
"pt": "Cache Write:",
|
||||
"es": "Cache Write:",
|
||||
"ar": "كتابة التخزين المؤقت:",
|
||||
"fr": "Cache Write:",
|
||||
"tr": "Önbellek Yazma:",
|
||||
"de": "Cache-Schreibung:",
|
||||
"uk": "Запис в кеш:"
|
||||
},
|
||||
"FEEDBACK$STAR_RATING": {
|
||||
"en": "★",
|
||||
"ja": "★",
|
||||
"zh-CN": "★",
|
||||
"zh-TW": "★",
|
||||
"ko-KR": "★",
|
||||
"no": "★",
|
||||
"it": "★",
|
||||
"pt": "★",
|
||||
"es": "★",
|
||||
"ar": "★",
|
||||
"fr": "★",
|
||||
"tr": "★",
|
||||
"de": "★",
|
||||
"uk": "★"
|
||||
},
|
||||
"BUTTON$CONFIRM": {
|
||||
"en": "Confirm",
|
||||
"ja": "確認",
|
||||
"zh-CN": "确认",
|
||||
"zh-TW": "確認",
|
||||
"ko-KR": "확인",
|
||||
"no": "Bekreft",
|
||||
"it": "Conferma",
|
||||
"pt": "Confirmar",
|
||||
"es": "Confirmar",
|
||||
"ar": "تأكيد",
|
||||
"fr": "Confirmer",
|
||||
"tr": "Onayla",
|
||||
"de": "Bestätigen",
|
||||
"uk": "Підтвердити"
|
||||
},
|
||||
"FORM$VALUE": {
|
||||
"en": "Value",
|
||||
"ja": "値",
|
||||
"zh-CN": "值",
|
||||
"zh-TW": "值",
|
||||
"ko-KR": "값",
|
||||
"no": "Verdi",
|
||||
"it": "Valore",
|
||||
"pt": "Valor",
|
||||
"es": "Valor",
|
||||
"ar": "القيمة",
|
||||
"fr": "Valeur",
|
||||
"tr": "Değer",
|
||||
"de": "Wert",
|
||||
"uk": "Значення"
|
||||
},
|
||||
"FORM$DESCRIPTION": {
|
||||
"en": "Description",
|
||||
"ja": "説明",
|
||||
"zh-CN": "描述",
|
||||
"zh-TW": "描述",
|
||||
"ko-KR": "설명",
|
||||
"no": "Beskrivelse",
|
||||
"it": "Descrizione",
|
||||
"pt": "Descrição",
|
||||
"es": "Descripción",
|
||||
"ar": "الوصف",
|
||||
"fr": "Description",
|
||||
"tr": "Açıklama",
|
||||
"de": "Beschreibung",
|
||||
"uk": "Опис"
|
||||
},
|
||||
"COMMON$OPTIONAL": {
|
||||
"en": "(Optional)",
|
||||
"ja": "(オプション)",
|
||||
"zh-CN": "(可选)",
|
||||
"zh-TW": "(可選)",
|
||||
"ko-KR": "(선택사항)",
|
||||
"no": "(Valgfritt)",
|
||||
"it": "(Opzionale)",
|
||||
"pt": "(Opcional)",
|
||||
"es": "(Opcional)",
|
||||
"ar": "(اختياري)",
|
||||
"fr": "(Optionnel)",
|
||||
"tr": "(İsteğe bağlı)",
|
||||
"de": "(Optional)",
|
||||
"uk": "(Необов'язково)"
|
||||
},
|
||||
"BROWSER$SERVER_MESSAGE": {
|
||||
"en": "If you tell OpenHands to start a web server, the app will appear here.",
|
||||
"ja": "OpenHandsにWebサーバーの起動を指示すると、アプリがここに表示されます。",
|
||||
"zh-CN": "如果您告诉OpenHands启动Web服务器,应用程序将在此处显示。",
|
||||
"zh-TW": "如果您告訴OpenHands啟動Web伺服器,應用程式將在此處顯示。",
|
||||
"ko-KR": "OpenHands에게 웹 서버를 시작하라고 말하면 앱이 여기에 나타납니다.",
|
||||
"no": "Hvis du ber OpenHands om å starte en webserver, vil appen vises her.",
|
||||
"it": "Se dici a OpenHands di avviare un server web, l'app apparirà qui.",
|
||||
"pt": "Se você disser ao OpenHands para iniciar um servidor web, o aplicativo aparecerá aqui.",
|
||||
"es": "Si le dices a OpenHands que inicie un servidor web, la aplicación aparecerá aquí.",
|
||||
"ar": "إذا أخبرت OpenHands ببدء خادم ويب، فستظهر التطبيق هنا.",
|
||||
"fr": "Si vous demandez à OpenHands de démarrer un serveur web, l'application apparaîtra ici.",
|
||||
"tr": "OpenHands'e bir web sunucusu başlatmasını söylerseniz, uygulama burada görünecektir.",
|
||||
"de": "Wenn Sie OpenHands anweisen, einen Webserver zu starten, wird die App hier angezeigt.",
|
||||
"uk": "Якщо ви скажете OpenHands запустити веб-сервер, додаток з'явиться тут."
|
||||
}
|
||||
}
|
||||
|
||||
24
frontend/src/icons/memory_icon.svg
Normal file
24
frontend/src/icons/memory_icon.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 22.3 18.66">
|
||||
<!-- Generator: Adobe Illustrator 29.5.1, SVG Export Plug-In . SVG Version: 2.1.0 Build 141) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
|
||||
.st0, .st1 {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="st1" d="M15.15,12.54h3.26c1.58,0,2.93-1.29,2.9-2.88-.03-1.53-1.28-2.77-2.82-2.77-.04,0-.08,0-.11,0,.13-.44.16-.92.04-1.43-.27-1.17-1.27-2.05-2.46-2.17-.74-.07-1.43.14-1.97.55,0,0,0-.02,0-.03,0-1.56-1.26-2.82-2.82-2.82s-2.82,1.26-2.82,2.82c0,0,0,.02,0,.03-.54-.4-1.23-.62-1.97-.55-1.19.12-2.19,1-2.46,2.17-.12.5-.09.99.04,1.43-.04,0-.08,0-.11,0-1.56,0-2.82,1.26-2.82,2.82s1.26,2.82,2.82,2.82l1.29.03c.41,0,.74.34.74.75v1.85c0,1.38,1.12,2.5,2.5,2.5h.29c1.44,0,2.6-1.17,2.6-2.6V6.49"/>
|
||||
<polyline class="st0" points="7.97 9.74 11.22 6.49 14.47 9.74"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -37,6 +37,7 @@ import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { TabContent } from "#/components/layout/tab-content";
|
||||
import { useIsAuthed } from "#/hooks/query/use-is-authed";
|
||||
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
|
||||
function AppContent() {
|
||||
@@ -195,23 +196,25 @@ function AppContent() {
|
||||
|
||||
return (
|
||||
<WsClientProvider conversationId={conversationId}>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
<ConversationSubscriptionsProvider>
|
||||
<EventHandler>
|
||||
<div data-testid="app-route" className="flex flex-col h-full gap-3">
|
||||
<div className="flex h-full overflow-auto">{renderMain()}</div>
|
||||
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
<Controls
|
||||
setSecurityOpen={onSecurityModalOpen}
|
||||
showSecurityLock={!!settings?.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
{settings && (
|
||||
<Security
|
||||
isOpen={securityModalIsOpen}
|
||||
onOpenChange={onSecurityModalOpenChange}
|
||||
securityAnalyzer={settings.SECURITY_ANALYZER}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</EventHandler>
|
||||
</ConversationSubscriptionsProvider>
|
||||
</WsClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user