mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
86 Commits
fix/locali
...
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 | ||
|
|
6da7e051be | ||
|
|
002e12a049 | ||
|
|
ed58858e03 | ||
|
|
11ae4f96c2 | ||
|
|
c2acf4e07e | ||
|
|
e9bdf761b7 | ||
|
|
04b93069b4 |
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
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
stale-issue-message: 'This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
stale-pr-message: 'This PR is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.'
|
||||
days-before-stale: 30
|
||||
exempt-issue-labels: 'tracked'
|
||||
exempt-issue-labels: 'roadmap'
|
||||
close-issue-message: 'This issue was closed because it has been stalled for over 30 days with no activity.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for over 30 days with no activity.'
|
||||
days-before-close: 7
|
||||
|
||||
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
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.48-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -62,17 +62,17 @@ system requirements and more information.
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
|
||||
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
```
|
||||
|
||||
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
|
||||
|
||||
@@ -42,17 +42,17 @@ OpenHandsはDockerを利用してローカル環境でも実行できます。
|
||||
> 公共ネットワークで実行していますか?[Hardened Docker Installation Guide](https://docs.all-hands.dev/usage/runtimes/docker#hardened-docker-installation)を参照して、ネットワークバインディングの制限や追加のセキュリティ対策を実施してください。
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
```
|
||||
|
||||
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
|
||||
|
||||
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
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.48-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -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
|
||||
@@ -64,7 +103,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +112,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
|
||||
python -m openhands.cli.main --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -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**:
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
@@ -92,4 +92,4 @@ Common command-line options:
|
||||
|
||||
Run `poetry run python -m openhands.core.main --help` for all options, or use a [`config.toml` file](https://github.com/All-Hands-AI/OpenHands/blob/main/config.template.toml) for more flexibility.
|
||||
|
||||
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
|
||||
Set `export LOG_ALL_EVENTS=true` to log all agent actions.
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -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
|
||||
|
||||
@@ -67,17 +67,17 @@ A system with a modern processor and a minimum of **4GB RAM** is recommended to
|
||||
### Start the App
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.47
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.48
|
||||
```
|
||||
|
||||
> **Note**: If you used OpenHands before version 0.44, you may want to run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history to the new location.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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({
|
||||
@@ -32,6 +33,31 @@ vi.mock("#/hooks/query/use-get-microagents", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -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"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -295,4 +295,238 @@ describe("ConversationPanel", () => {
|
||||
const newCards = await screen.findAllByTestId("conversation-card");
|
||||
expect(newCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should cancel stopping a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Create mock data with a RUNNING conversation
|
||||
const mockRunningConversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
title: "Stopped Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-03T12:00:00Z",
|
||||
created_at: "2021-10-03T12:00:00Z",
|
||||
status: "STOPPED" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockRunningConversations);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
|
||||
// Click ellipsis on the first card (RUNNING status)
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
// Stop button should be available for RUNNING conversation
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
expect(stopButton).toBeInTheDocument();
|
||||
|
||||
// Click the stop button
|
||||
await user.click(stopButton);
|
||||
|
||||
// Cancel the stopping action
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /cancel/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Ensure the conversation status hasn't changed
|
||||
const updatedCards = await screen.findAllByTestId("conversation-card");
|
||||
expect(updatedCards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("should stop a conversation", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockData: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockImplementation(async () => mockData);
|
||||
|
||||
const stopConversationSpy = vi.spyOn(OpenHands, "stopConversation");
|
||||
stopConversationSpy.mockImplementation(async (id: string) => {
|
||||
const conversation = mockData.find((conv) => conv.conversation_id === id);
|
||||
if (conversation) {
|
||||
conversation.status = "STOPPED";
|
||||
return conversation;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(2);
|
||||
|
||||
// Click ellipsis on the first card (RUNNING status)
|
||||
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
|
||||
await user.click(ellipsisButton);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-button");
|
||||
|
||||
// Click the stop button
|
||||
await user.click(stopButton);
|
||||
|
||||
// Confirm the stopping action
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i });
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /confirm/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Verify the API was called
|
||||
expect(stopConversationSpy).toHaveBeenCalledWith("1");
|
||||
expect(stopConversationSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should only show stop button for STARTING or RUNNING conversations", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
const mockMixedStatusConversations: Conversation[] = [
|
||||
{
|
||||
conversation_id: "1",
|
||||
title: "Running Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-01T12:00:00Z",
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
status: "RUNNING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "2",
|
||||
title: "Starting Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-02T12:00:00Z",
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
status: "STARTING" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
{
|
||||
conversation_id: "3",
|
||||
title: "Stopped Conversation",
|
||||
selected_repository: null,
|
||||
git_provider: null,
|
||||
selected_branch: null,
|
||||
last_updated_at: "2021-10-03T12:00:00Z",
|
||||
created_at: "2021-10-03T12:00:00Z",
|
||||
status: "STOPPED" as const,
|
||||
runtime_status: null,
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
},
|
||||
];
|
||||
|
||||
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
|
||||
getUserConversationsSpy.mockResolvedValue(mockMixedStatusConversations);
|
||||
|
||||
renderConversationPanel();
|
||||
|
||||
const cards = await screen.findAllByTestId("conversation-card");
|
||||
expect(cards).toHaveLength(3);
|
||||
|
||||
// Test RUNNING conversation - should show stop button
|
||||
const runningEllipsisButton = within(cards[0]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(runningEllipsisButton);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Test STARTING conversation - should show stop button
|
||||
const startingEllipsisButton = within(cards[1]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(startingEllipsisButton);
|
||||
|
||||
expect(screen.getByTestId("stop-button")).toBeInTheDocument();
|
||||
|
||||
// Click outside to close the menu
|
||||
await user.click(document.body);
|
||||
|
||||
// Test STOPPED conversation - should NOT show stop button
|
||||
const stoppedEllipsisButton = within(cards[2]).getByTestId(
|
||||
"ellipsis-button",
|
||||
);
|
||||
await user.click(stoppedEllipsisButton);
|
||||
|
||||
expect(screen.queryByTestId("stop-button")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -257,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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,12 @@ describe("UserActions", () => {
|
||||
});
|
||||
|
||||
it("should toggle the user menu when the user avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
@@ -57,15 +62,102 @@ describe("UserActions", () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("logout button is always enabled", async () => {
|
||||
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
|
||||
await user.click(logoutOption);
|
||||
// Context menu should NOT appear because user is undefined
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(onLogoutMock).toHaveBeenCalledOnce();
|
||||
it("should show context menu even when user has no avatar_url", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} user={{ avatar_url: "" }} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu SHOULD appear because user object exists (even with empty avatar_url)
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should NOT be able to access logout when no user is provided", async () => {
|
||||
render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Logout option should not be accessible because context menu doesn't appear
|
||||
expect(
|
||||
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(onLogoutMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from undefined to defined", () => {
|
||||
const { rerender } = render(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
// Initially no user - context menu shouldn't work
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Add user prop
|
||||
rerender(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Component should still render correctly
|
||||
expect(screen.getByTestId("user-actions")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("user-avatar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle user prop changing from defined to undefined", async () => {
|
||||
const { rerender } = render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click to open menu
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Remove user prop - menu should disappear
|
||||
rerender(<UserActions onLogout={onLogoutMock} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("account-settings-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should work with loading state and user provided", async () => {
|
||||
render(
|
||||
<UserActions
|
||||
onLogout={onLogoutMock}
|
||||
user={{ avatar_url: "https://example.com/avatar.png" }}
|
||||
isLoading={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const userAvatar = screen.getByTestId("user-avatar");
|
||||
await user.click(userAvatar);
|
||||
|
||||
// Context menu should still appear even when loading
|
||||
expect(
|
||||
screen.getByTestId("account-settings-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
2465
frontend/package-lock.json
generated
2465
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.47.0",
|
||||
"version": "0.48.0",
|
||||
"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.19.2",
|
||||
"i18next": "^25.2.1",
|
||||
"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.255.1",
|
||||
"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,11 +79,11 @@
|
||||
]
|
||||
},
|
||||
"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.1",
|
||||
"@playwright/test": "^1.53.2",
|
||||
"@react-router/dev": "^7.6.3",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/eslint-plugin-query": "^5.81.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.5",
|
||||
"@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,740 +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
|
||||
".openhands/microagents/", // Path to microagents directory
|
||||
"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);
|
||||
}
|
||||
@@ -71,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 {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChatInput({
|
||||
disabled,
|
||||
showButton = true,
|
||||
value,
|
||||
maxRows = 16,
|
||||
maxRows = 8,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onChange,
|
||||
|
||||
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal file
164
frontend/src/components/features/chat/chat-interface.test.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { useParams } from "react-router";
|
||||
import { vi, describe, test, expect, beforeEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ChatInterface } from "./chat-interface";
|
||||
import { useWsClient } from "#/context/ws-client-provider";
|
||||
import { useOptimisticUserMessage } from "#/hooks/use-optimistic-user-message";
|
||||
import { useWSErrorMessage } from "#/hooks/use-ws-error-message";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
|
||||
import { useUploadFiles } from "#/hooks/mutation/use-upload-files";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock("#/context/ws-client-provider");
|
||||
vi.mock("#/hooks/use-optimistic-user-message");
|
||||
vi.mock("#/hooks/use-ws-error-message");
|
||||
vi.mock("react-router");
|
||||
vi.mock("#/hooks/query/use-config");
|
||||
vi.mock("#/hooks/mutation/use-get-trajectory");
|
||||
vi.mock("#/hooks/mutation/use-upload-files");
|
||||
vi.mock("react-redux", () => ({
|
||||
useSelector: vi.fn(() => ({
|
||||
curAgentState: "AWAITING_USER_INPUT",
|
||||
selectedRepository: null,
|
||||
replayJson: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("ChatInterface", () => {
|
||||
// Create a new QueryClient for each test
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Default mock implementations
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => null),
|
||||
});
|
||||
(useWSErrorMessage as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
getErrorMessage: vi.fn(() => null),
|
||||
setErrorMessage: vi.fn(),
|
||||
removeErrorMessage: vi.fn(),
|
||||
});
|
||||
(useParams as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
conversationId: "test-id",
|
||||
});
|
||||
(useConfig as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
data: { APP_MODE: "local" },
|
||||
});
|
||||
(useGetTrajectory as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isLoading: false,
|
||||
});
|
||||
(useUploadFiles as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
mutateAsync: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ skipped_files: [], uploaded_files: [] }),
|
||||
isLoading: false,
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to render with QueryClientProvider
|
||||
const renderWithQueryClient = (ui: React.ReactElement) =>
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
|
||||
test("should show chat suggestions when there are no events", () => {
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is rendered
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should show chat suggestions when there are only environment events", () => {
|
||||
const environmentEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "environment",
|
||||
action: "system",
|
||||
args: {
|
||||
content: "source .openhands/setup.sh",
|
||||
tools: null,
|
||||
openhands_version: null,
|
||||
agent_class: null,
|
||||
},
|
||||
message: "Running setup script",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [environmentEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is still rendered with environment events
|
||||
expect(screen.getByTestId("chat-suggestions")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is a user message", () => {
|
||||
const userEvent: OpenHandsAction = {
|
||||
id: 1,
|
||||
source: "user",
|
||||
action: "message",
|
||||
args: {
|
||||
content: "Hello",
|
||||
image_urls: [],
|
||||
file_urls: [],
|
||||
},
|
||||
message: "Hello",
|
||||
timestamp: "2025-07-01T00:00:00Z",
|
||||
};
|
||||
|
||||
(useWsClient as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
send: vi.fn(),
|
||||
isLoadingMessages: false,
|
||||
parsedEvents: [userEvent],
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with user events
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("should hide chat suggestions when there is an optimistic user message", () => {
|
||||
(
|
||||
useOptimisticUserMessage as unknown as ReturnType<typeof vi.fn>
|
||||
).mockReturnValue({
|
||||
setOptimisticUserMessage: vi.fn(),
|
||||
getOptimisticUserMessage: vi.fn(() => "Optimistic message"),
|
||||
});
|
||||
|
||||
renderWithQueryClient(<ChatInterface />);
|
||||
|
||||
// Check if ChatSuggestions is not rendered with optimistic user message
|
||||
expect(screen.queryByTestId("chat-suggestions")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { createChatMessage } from "#/services/chat-service";
|
||||
import { InteractiveChatBox } from "./interactive-chat-box";
|
||||
import { RootState } from "#/store";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { isOpenHandsAction } from "#/types/core/guards";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
import { FeedbackModal } from "../feedback/feedback-modal";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
@@ -31,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,
|
||||
@@ -77,11 +79,26 @@ export function ChatInterface() {
|
||||
|
||||
const events = parsedEvents.filter(shouldRenderEvent);
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
() =>
|
||||
parsedEvents.some(
|
||||
(event) =>
|
||||
isOpenHandsAction(event) &&
|
||||
event.source === "agent" &&
|
||||
event.action !== "system",
|
||||
),
|
||||
[parsedEvents],
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -97,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);
|
||||
|
||||
@@ -167,9 +194,12 @@ export function ChatInterface() {
|
||||
return (
|
||||
<ScrollProvider value={scrollProviderValue}>
|
||||
<div className="h-full flex flex-col justify-between">
|
||||
{events.length === 0 && !optimisticUserMessage && (
|
||||
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
|
||||
)}
|
||||
{!hasSubstantiveAgentActions &&
|
||||
!optimisticUserMessage &&
|
||||
!events.some(
|
||||
(event) => isOpenHandsAction(event) && event.source === "user",
|
||||
) && <ChatSuggestions onSuggestionsClick={setMessageToSend} />}
|
||||
{/* Note: We only hide chat suggestions when there's a user message */}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
@@ -192,7 +222,7 @@ export function ChatInterface() {
|
||||
)}
|
||||
|
||||
{isWaitingForUserInput &&
|
||||
events.length > 0 &&
|
||||
hasSubstantiveAgentActions &&
|
||||
!optimisticUserMessage && (
|
||||
<ActionSuggestions
|
||||
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
|
||||
|
||||
@@ -12,7 +12,10 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 h-full px-4 items-center justify-center">
|
||||
<div
|
||||
data-testid="chat-suggestions"
|
||||
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
|
||||
>
|
||||
<div className="flex flex-col items-center p-4 bg-tertiary rounded-xl w-full">
|
||||
<BuildIt width={45} height={54} />
|
||||
<span className="font-semibold text-[20px] leading-6 -tracking-[0.01em] gap-1">
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FaBrain } from "react-icons/fa6";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
import {
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} 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)[];
|
||||
@@ -208,15 +208,21 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
message.id,
|
||||
)}
|
||||
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
|
||||
actions={[
|
||||
{
|
||||
icon: <FaBrain className="w-[14px] h-[14px]" />,
|
||||
onClick: () => {
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
},
|
||||
]}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
BaseModalDescription,
|
||||
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 { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ConfirmStopModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmStopModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmStopModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start border border-tertiary">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex flex-col gap-2 w-full"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
className="w-full"
|
||||
data-testid="confirm-button"
|
||||
>
|
||||
{t(I18nKey.ACTION$CONFIRM)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
className="w-full"
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
interface ConversationCardContextMenuProps {
|
||||
onClose: () => void;
|
||||
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onStop?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDisplayCost?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onShowAgentTools?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
@@ -19,6 +20,7 @@ interface ConversationCardContextMenuProps {
|
||||
export function ConversationCardContextMenu({
|
||||
onClose,
|
||||
onDelete,
|
||||
onStop,
|
||||
onEdit,
|
||||
onDisplayCost,
|
||||
onShowAgentTools,
|
||||
@@ -41,12 +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}>
|
||||
{t(I18nKey.BUTTON$STOP)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onEdit && (
|
||||
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
|
||||
Edit Title
|
||||
{t(I18nKey.BUTTON$EDIT_TITLE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDownloadViaVSCode && (
|
||||
@@ -54,7 +61,7 @@ export function ConversationCardContextMenu({
|
||||
testId="download-vscode-button"
|
||||
onClick={onDownloadViaVSCode}
|
||||
>
|
||||
Download via VS Code
|
||||
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onDisplayCost && (
|
||||
@@ -62,7 +69,7 @@ export function ConversationCardContextMenu({
|
||||
testId="display-cost-button"
|
||||
onClick={onDisplayCost}
|
||||
>
|
||||
Display Cost
|
||||
{t(I18nKey.BUTTON$DISPLAY_COST)}
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
{onShowAgentTools && (
|
||||
@@ -70,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,15 +19,17 @@ 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;
|
||||
onDelete?: () => void;
|
||||
onStop?: () => void;
|
||||
onChangeTitle?: (title: string) => void;
|
||||
showOptions?: boolean;
|
||||
isActive?: boolean;
|
||||
title: string;
|
||||
selectedRepository: string | null;
|
||||
selectedRepository: RepositorySelection | null;
|
||||
lastUpdatedAt: string; // ISO 8601
|
||||
createdAt?: string; // ISO 8601
|
||||
conversationStatus?: ConversationStatus;
|
||||
@@ -40,6 +42,7 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
|
||||
export function ConversationCard({
|
||||
onClick,
|
||||
onDelete,
|
||||
onStop,
|
||||
onChangeTitle,
|
||||
showOptions,
|
||||
isActive,
|
||||
@@ -101,6 +104,13 @@ export function ConversationCard({
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onStop?.();
|
||||
setContextMenuVisible(false);
|
||||
};
|
||||
|
||||
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -171,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]",
|
||||
)}
|
||||
@@ -224,6 +234,11 @@ export function ConversationCard({
|
||||
<ConversationCardContextMenu
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
onDelete={onDelete && handleDelete}
|
||||
onStop={
|
||||
conversationStatus !== "STOPPED"
|
||||
? onStop && handleStop
|
||||
: undefined
|
||||
}
|
||||
onEdit={onChangeTitle && handleEdit}
|
||||
onDownloadViaVSCode={
|
||||
conversationId && showOptions
|
||||
@@ -250,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>
|
||||
|
||||
@@ -310,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>
|
||||
|
||||
@@ -5,10 +5,13 @@ import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationCard } from "./conversation-card";
|
||||
import { useUserConversations } from "#/hooks/query/use-user-conversations";
|
||||
import { useDeleteConversation } from "#/hooks/mutation/use-delete-conversation";
|
||||
import { useStopConversation } from "#/hooks/mutation/use-stop-conversation";
|
||||
import { ConfirmDeleteModal } from "./confirm-delete-modal";
|
||||
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;
|
||||
@@ -22,6 +25,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
|
||||
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
|
||||
React.useState(false);
|
||||
const [confirmStopModalVisible, setConfirmStopModalVisible] =
|
||||
React.useState(false);
|
||||
const [
|
||||
confirmExitConversationModalVisible,
|
||||
setConfirmExitConversationModalVisible,
|
||||
@@ -33,12 +38,18 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
const { data: conversations, isFetching, error } = useUserConversations();
|
||||
|
||||
const { mutate: deleteConversation } = useDeleteConversation();
|
||||
const { mutate: stopConversation } = useStopConversation();
|
||||
|
||||
const handleDeleteProject = (conversationId: string) => {
|
||||
setConfirmDeleteModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleStopConversation = (conversationId: string) => {
|
||||
setConfirmStopModalVisible(true);
|
||||
setSelectedConversationId(conversationId);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (selectedConversationId) {
|
||||
deleteConversation(
|
||||
@@ -54,6 +65,21 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmStop = () => {
|
||||
if (selectedConversationId) {
|
||||
stopConversation(
|
||||
{ conversationId: selectedConversationId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (selectedConversationId === currentConversationId) {
|
||||
navigate("/");
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
@@ -87,8 +113,13 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
<ConversationCard
|
||||
isActive={isActive}
|
||||
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}
|
||||
@@ -108,6 +139,16 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmStopModalVisible && (
|
||||
<ConfirmStopModal
|
||||
onConfirm={() => {
|
||||
handleConfirmStop();
|
||||
setConfirmStopModalVisible(false);
|
||||
}}
|
||||
onCancel={() => setConfirmStopModalVisible(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmExitConversationModalVisible && (
|
||||
<ExitConversationModal
|
||||
onConfirm={() => {
|
||||
|
||||
@@ -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,11 +1,15 @@
|
||||
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;
|
||||
@@ -13,14 +17,16 @@ interface 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,
|
||||
refetch,
|
||||
isRefetching,
|
||||
} = useConversationMicroagents();
|
||||
|
||||
const toggleAgent = (agentName: string) => {
|
||||
@@ -30,6 +36,10 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
}));
|
||||
};
|
||||
|
||||
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
|
||||
curAgentState,
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalBackdrop onClose={onClose}>
|
||||
<ModalBody
|
||||
@@ -38,10 +48,40 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
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" />
|
||||
@@ -49,6 +89,7 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
isAgentReady &&
|
||||
(isError || !microagents || microagents.length === 0) && (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<p className="text-gray-400">
|
||||
@@ -59,75 +100,81 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
|
||||
</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) */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
} from "./repository-selection";
|
||||
|
||||
interface RepositorySelectionFormProps {
|
||||
onRepoSelection: (repoTitle: string | null) => void;
|
||||
onRepoSelection: (repo: GitRepository | null) => void;
|
||||
}
|
||||
|
||||
export function RepositorySelectionForm({
|
||||
@@ -96,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
|
||||
|
||||
@@ -53,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,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>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{accountContextMenuIsVisible && (
|
||||
{accountContextMenuIsVisible && !!user && (
|
||||
<AccountSettingsContextMenu
|
||||
onLogout={handleLogout}
|
||||
onClose={closeAccountMenu}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,13 +20,12 @@ export function ActionButton({
|
||||
<button
|
||||
onClick={() => handleAction(action)}
|
||||
disabled={isDisabled}
|
||||
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
|
||||
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-colors duration-300 ease-in-out border border-transparent hover:border-red-400/40 rounded-full p-1"
|
||||
type="button"
|
||||
>
|
||||
<span className="relative group-hover:filter group-hover:drop-shadow-[0_0_5px_rgba(255,64,0,0.4)]">
|
||||
{children}
|
||||
</span>
|
||||
<span className="absolute -inset-[5px] border-2 border-red-400/40 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300 ease-in-out" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -85,12 +85,6 @@ export function ConversationSubscriptionsProvider({
|
||||
// Store the current sockets in a local variable to avoid closure issues
|
||||
const socketsToDisconnect = { ...conversationSockets };
|
||||
|
||||
if (Object.keys(socketsToDisconnect).length > 0) {
|
||||
console.warn(
|
||||
`Cleaning up ${Object.keys(socketsToDisconnect).length} socket connections`,
|
||||
);
|
||||
}
|
||||
|
||||
Object.values(socketsToDisconnect).forEach((socketData) => {
|
||||
if (socketData.socket) {
|
||||
socketData.socket.removeAllListeners();
|
||||
@@ -103,8 +97,6 @@ export function ConversationSubscriptionsProvider({
|
||||
|
||||
const unsubscribeFromConversation = useCallback(
|
||||
(conversationId: string) => {
|
||||
console.warn(`Unsubscribing from conversation ${conversationId}`);
|
||||
|
||||
// Get a local reference to the socket data to avoid race conditions
|
||||
const socketData = conversationSockets[conversationId];
|
||||
|
||||
@@ -113,20 +105,11 @@ export function ConversationSubscriptionsProvider({
|
||||
const handler = eventHandlersRef.current[conversationId];
|
||||
|
||||
if (socket) {
|
||||
// First remove specific event handlers
|
||||
if (handler) {
|
||||
socket.off("oh_event", handler);
|
||||
}
|
||||
|
||||
// Then remove all listeners to be safe
|
||||
socket.removeAllListeners();
|
||||
|
||||
// Finally disconnect the socket
|
||||
socket.disconnect();
|
||||
|
||||
console.warn(
|
||||
`Socket for conversation ${conversationId} disconnected`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update state to remove the socket
|
||||
@@ -161,13 +144,9 @@ export function ConversationSubscriptionsProvider({
|
||||
|
||||
// If already subscribed, don't create a new subscription
|
||||
if (conversationSockets[conversationId]) {
|
||||
console.warn(`Already subscribed to conversation ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`Subscribing to conversation ${conversationId}`);
|
||||
|
||||
// Create event handler for this subscription
|
||||
const handleOhEvent = (event: unknown) => {
|
||||
// Call the custom event handler if provided
|
||||
if (onEvent) {
|
||||
@@ -232,7 +211,6 @@ export function ConversationSubscriptionsProvider({
|
||||
|
||||
// Set up event listeners
|
||||
socket.on("connect", () => {
|
||||
console.warn(`Socket for conversation ${conversationId} CONNECTED!`);
|
||||
setConversationSockets((prev) => {
|
||||
// Make sure the conversation still exists in our state
|
||||
if (!prev[conversationId]) return prev;
|
||||
@@ -289,15 +267,7 @@ export function ConversationSubscriptionsProvider({
|
||||
setActiveConversationIds((prev) =>
|
||||
prev.includes(conversationId) ? prev : [...prev, conversationId],
|
||||
);
|
||||
|
||||
console.warn(
|
||||
`Successfully subscribed to conversation ${conversationId}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error subscribing to conversation ${conversationId}:`,
|
||||
error,
|
||||
);
|
||||
// Clean up the event handler if there was an error
|
||||
delete eventHandlersRef.current[conversationId];
|
||||
}
|
||||
|
||||
31
frontend/src/hooks/mutation/use-stop-conversation.ts
Normal file
31
frontend/src/hooks/mutation/use-stop-conversation.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useStopConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (variables: { conversationId: string }) =>
|
||||
OpenHands.stopConversation(variables.conversationId),
|
||||
onMutate: async () => {
|
||||
await queryClient.cancelQueries({ queryKey: ["user", "conversations"] });
|
||||
const previousConversations = queryClient.getQueryData([
|
||||
"user",
|
||||
"conversations",
|
||||
]);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (_, __, context) => {
|
||||
if (context?.previousConversations) {
|
||||
queryClient.setQueryData(
|
||||
["user", "conversations"],
|
||||
context.previousConversations,
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "conversations"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
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";
|
||||
|
||||
export const useConversationMicroagents = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["conversation", conversationId, "microagents"],
|
||||
@@ -14,7 +18,10 @@ export const useConversationMicroagents = () => {
|
||||
const data = await OpenHands.getMicroagents(conversationId);
|
||||
return data.microagents;
|
||||
},
|
||||
enabled: !!conversationId,
|
||||
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
|
||||
});
|
||||
|
||||
@@ -118,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",
|
||||
@@ -280,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",
|
||||
@@ -311,12 +315,18 @@ 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",
|
||||
LANDING$UPLOAD_TRAJECTORY = "LANDING$UPLOAD_TRAJECTORY",
|
||||
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
|
||||
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
|
||||
CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP",
|
||||
CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING",
|
||||
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
|
||||
CONVERSATION$CREATED = "CONVERSATION$CREATED",
|
||||
CONVERSATION$AGO = "CONVERSATION$AGO",
|
||||
@@ -366,6 +376,7 @@ export enum I18nKey {
|
||||
SETTINGS$DONT_KNOW_API_KEY = "SETTINGS$DONT_KNOW_API_KEY",
|
||||
SETTINGS$CLICK_FOR_INSTRUCTIONS = "SETTINGS$CLICK_FOR_INSTRUCTIONS",
|
||||
SETTINGS$SAVED = "SETTINGS$SAVED",
|
||||
SETTINGS$SAVED_WARNING = "SETTINGS$SAVED_WARNING",
|
||||
SETTINGS$RESET = "SETTINGS$RESET",
|
||||
SETTINGS$API_KEYS = "SETTINGS$API_KEYS",
|
||||
SETTINGS$API_KEYS_DESCRIPTION = "SETTINGS$API_KEYS_DESCRIPTION",
|
||||
@@ -375,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",
|
||||
@@ -583,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",
|
||||
@@ -651,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;
|
||||
|
||||
@@ -1888,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": "アカウント設定",
|
||||
@@ -4401,7 +4449,7 @@
|
||||
"uk": "Зупинено"
|
||||
},
|
||||
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
|
||||
"en": "Initializing Agent...",
|
||||
"en": "Initializing agent...",
|
||||
"de": "Agent wird initialisiert...",
|
||||
"zh-CN": "正在初始化智能体...",
|
||||
"zh-TW": "正在初始化智能體...",
|
||||
@@ -4465,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.",
|
||||
@@ -4976,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": "画像を添付",
|
||||
@@ -5072,6 +5200,38 @@
|
||||
"de": "Löschen bestätigen",
|
||||
"uk": "Підтвердити видалення"
|
||||
},
|
||||
"CONVERSATION$CONFIRM_STOP": {
|
||||
"en": "Confirm Stop",
|
||||
"ja": "停止の確認",
|
||||
"zh-CN": "确认停止",
|
||||
"zh-TW": "確認停止",
|
||||
"ko-KR": "중지 확인",
|
||||
"no": "Bekreft stopp",
|
||||
"it": "Conferma arresto",
|
||||
"pt": "Confirmar parada",
|
||||
"es": "Confirmar detención",
|
||||
"ar": "تأكيد الإيقاف",
|
||||
"fr": "Confirmer l'arrêt",
|
||||
"tr": "Durdurmayı Onayla",
|
||||
"de": "Stopp bestätigen",
|
||||
"uk": "Підтвердити зупинку"
|
||||
},
|
||||
"CONVERSATION$STOP_WARNING": {
|
||||
"en": "Are you sure you want to stop this conversation?",
|
||||
"ja": "この会話を停止してもよろしいですか?",
|
||||
"zh-CN": "您确定要停止此对话吗?",
|
||||
"zh-TW": "您確定要停止此對話嗎?",
|
||||
"ko-KR": "이 대화를 중지하시겠습니까?",
|
||||
"no": "Er du sikker på at du vil stoppe denne samtalen?",
|
||||
"it": "Sei sicuro di voler fermare questa conversazione?",
|
||||
"pt": "Tem certeza de que deseja parar esta conversa?",
|
||||
"es": "¿Está seguro de que desea detener esta conversación?",
|
||||
"ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة؟",
|
||||
"fr": "Êtes-vous sûr de vouloir arrêter cette conversation ?",
|
||||
"tr": "Bu konuşmayı durdurmak istediğinizden emin misiniz?",
|
||||
"de": "Sind Sie sicher, dass Sie dieses Gespräch stoppen möchten?",
|
||||
"uk": "Ви впевнені, що хочете зупинити цю розмову?"
|
||||
},
|
||||
"CONVERSATION$METRICS_INFO": {
|
||||
"en": "Conversation Metrics",
|
||||
"ja": "会話メトリクス",
|
||||
@@ -5856,6 +6016,22 @@
|
||||
"de": "Einstellungen gespeichert",
|
||||
"uk": "Налаштування збережено"
|
||||
},
|
||||
"SETTINGS$SAVED_WARNING": {
|
||||
"en": "Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.",
|
||||
"ja": "設定が保存されました。古い会話では、変更を確認するために会話を停止して再開する必要があります。",
|
||||
"zh-CN": "设置已保存。对于旧对话,您需要停止并重新开始对话才能看到更改。",
|
||||
"zh-TW": "設置已保存。對於舊對話,您需要停止並重新開始對話才能看到更改。",
|
||||
"ko-KR": "설정이 저장되었습니다. 기존 대화의 경우 변경사항을 보려면 대화를 중지하고 다시 시작해야 합니다.",
|
||||
"no": "Innstillinger lagret. For gamle samtaler må du stoppe og starte samtalen på nytt for å se endringene.",
|
||||
"it": "Impostazioni salvate. Per le conversazioni precedenti, dovrai fermare e riavviare la conversazione per vedere le modifiche.",
|
||||
"pt": "Configurações salvas. Para conversas antigas, você precisará parar e reiniciar a conversa para ver as alterações.",
|
||||
"es": "Configuración guardada. Para conversaciones antiguas, necesitarás detener y reiniciar la conversación para ver los cambios.",
|
||||
"ar": "تم حفظ الإعدادات. بالنسبة للمحادثات القديمة، ستحتاج إلى إيقاف وإعادة تشغيل المحادثة لرؤية التغييرات.",
|
||||
"fr": "Paramètres enregistrés. Pour les anciennes conversations, vous devrez arrêter et redémarrer la conversation pour voir les changements.",
|
||||
"tr": "Ayarlar kaydedildi. Eski konuşmalar için değişiklikleri görmek üzere konuşmayı durdurup yeniden başlatmanız gerekecek.",
|
||||
"de": "Einstellungen gespeichert. Für alte Gespräche müssen Sie das Gespräch stoppen und neu starten, um die Änderungen zu sehen.",
|
||||
"uk": "Налаштування збережено. Для старих розмов вам потрібно буде зупинити та перезапустити розмову, щоб побачити зміни."
|
||||
},
|
||||
"SETTINGS$RESET": {
|
||||
"en": "Settings reset",
|
||||
"ja": "設定がリセットされました",
|
||||
@@ -6000,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": "Префікс ключа",
|
||||
@@ -9328,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": "トリガー",
|
||||
@@ -10415,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 |
@@ -4,14 +4,15 @@ import { HomeHeader } from "#/components/features/home/home-header";
|
||||
import { RepoConnector } from "#/components/features/home/repo-connector";
|
||||
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
<PrefetchPageLinks page="/conversations/:conversationId" />;
|
||||
|
||||
function HomeScreen() {
|
||||
const { providers } = useUserProviders();
|
||||
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedRepo, setSelectedRepo] = React.useState<GitRepository | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
@@ -25,11 +26,9 @@ function HomeScreen() {
|
||||
<hr className="border-[#717888]" />
|
||||
|
||||
<main className="flex flex-col lg:flex-row justify-between gap-8">
|
||||
<RepoConnector
|
||||
onRepoSelection={(title) => setSelectedRepoTitle(title)}
|
||||
/>
|
||||
<RepoConnector onRepoSelection={(repo) => setSelectedRepo(repo)} />
|
||||
<hr className="md:hidden border-[#717888]" />
|
||||
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
|
||||
{providersAreSet && <TaskSuggestions filterFor={selectedRepo} />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,15 +7,36 @@ function Jupyter() {
|
||||
|
||||
// This is a hack to prevent the editor from overflowing
|
||||
// Should be removed after revising the parent and containers
|
||||
// Use ResizeObserver to properly track parent width changes
|
||||
React.useEffect(() => {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
// Use contentRect.width for more accurate measurements
|
||||
const { width } = entry.contentRect;
|
||||
if (width > 0) {
|
||||
setParentWidth(width);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (parentRef.current) {
|
||||
setParentWidth(parentRef.current.offsetWidth);
|
||||
resizeObserver.observe(parentRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Provide a fallback width to prevent the editor from being hidden
|
||||
// Use parentWidth if available, otherwise use a large default
|
||||
const maxWidth = parentWidth > 0 ? parentWidth : 9999;
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-full">
|
||||
<JupyterEditor maxWidth={parentWidth} />
|
||||
<JupyterEditor maxWidth={maxWidth} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ function LlmSettingsScreen() {
|
||||
}, [settings, resources]);
|
||||
|
||||
const handleSuccessfulMutation = () => {
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
|
||||
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
|
||||
setDirtyInputs({
|
||||
model: false,
|
||||
apiKey: false,
|
||||
|
||||
@@ -100,28 +100,6 @@ function SecretsSettingsScreen() {
|
||||
<p data-testid="no-secrets-message">{t("SECRETS$NO_SECRETS_FOUND")}</p>
|
||||
)}
|
||||
|
||||
{view === "list" && (
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{secrets?.map((secret) => (
|
||||
<SecretListItem
|
||||
key={secret.name}
|
||||
title={secret.name}
|
||||
description={secret.description}
|
||||
onEdit={() => {
|
||||
setView("edit-secret-form");
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setConfirmationModalIsVisible(true);
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!shouldRenderConnectToGitButton && view === "list" && (
|
||||
<BrandButton
|
||||
testId="add-secret-button"
|
||||
@@ -134,6 +112,43 @@ function SecretsSettingsScreen() {
|
||||
</BrandButton>
|
||||
)}
|
||||
|
||||
{view === "list" && (
|
||||
<div className="border border-tertiary rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-base-tertiary">
|
||||
<tr className="flex w-full items-center">
|
||||
<th className="w-1/4 text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$NAME)}
|
||||
</th>
|
||||
<th className="w-1/2 text-left p-3 text-sm font-medium">
|
||||
{t(I18nKey.SECRETS$DESCRIPTION)}
|
||||
</th>
|
||||
<th className="w-1/4 text-right p-3 text-sm font-medium">
|
||||
{t(I18nKey.SETTINGS$ACTIONS)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secrets?.map((secret) => (
|
||||
<SecretListItem
|
||||
key={secret.name}
|
||||
title={secret.name}
|
||||
description={secret.description}
|
||||
onEdit={() => {
|
||||
setView("edit-secret-form");
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setConfirmationModalIsVisible(true);
|
||||
setSelectedSecret(secret.name);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(view === "add-secret-form" || view === "edit-secret-form") && (
|
||||
<SecretForm
|
||||
mode={view === "add-secret-form" ? "add" : "edit"}
|
||||
|
||||
@@ -50,7 +50,7 @@ function ServedApp() {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full p-10">
|
||||
<span className="text-neutral-400 font-bold">
|
||||
If you tell OpenHands to start a web server, the app will appear here.
|
||||
{t(I18nKey.BROWSER$SERVER_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,70 @@
|
||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router";
|
||||
import { NavLink, Outlet, redirect } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import SettingsIcon from "#/icons/settings.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Route } from "./+types/settings";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
const { data: config } = useConfig();
|
||||
const SAAS_ONLY_PATHS = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
|
||||
const SAAS_NAV_ITEMS = [
|
||||
{ to: "/settings/user", text: "SETTINGS$NAV_USER" },
|
||||
{ to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" },
|
||||
{ to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" },
|
||||
{ to: "/settings/billing", text: "SETTINGS$NAV_CREDITS" },
|
||||
{ to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" },
|
||||
{ to: "/settings/api-keys", text: "SETTINGS$NAV_API_KEYS" },
|
||||
];
|
||||
|
||||
const OSS_NAV_ITEMS = [
|
||||
{ to: "/settings", text: "SETTINGS$NAV_LLM" },
|
||||
{ to: "/settings/mcp", text: "SETTINGS$NAV_MCP" },
|
||||
{ to: "/settings/integrations", text: "SETTINGS$NAV_INTEGRATIONS" },
|
||||
{ to: "/settings/app", text: "SETTINGS$NAV_APPLICATION" },
|
||||
{ to: "/settings/secrets", text: "SETTINGS$NAV_SECRETS" },
|
||||
];
|
||||
|
||||
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const { pathname } = url;
|
||||
|
||||
let config = queryClient.getQueryData<GetConfigResponse>(["config"]);
|
||||
if (!config) {
|
||||
config = await OpenHands.getConfig();
|
||||
queryClient.setQueryData<GetConfigResponse>(["config"], config);
|
||||
}
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
|
||||
const saasNavItems = [
|
||||
{ to: "/settings/user", text: t("SETTINGS$NAV_USER") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/billing", text: t("SETTINGS$NAV_CREDITS") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
{ to: "/settings/api-keys", text: t("SETTINGS$NAV_API_KEYS") },
|
||||
];
|
||||
if (isSaas && pathname === "/settings") {
|
||||
// no llm settings in saas mode, so redirect to user settings
|
||||
return redirect("/settings/user");
|
||||
}
|
||||
|
||||
const ossNavItems = [
|
||||
{ to: "/settings", text: t("SETTINGS$NAV_LLM") },
|
||||
{ to: "/settings/mcp", text: t("SETTINGS$NAV_MCP") },
|
||||
{ to: "/settings/integrations", text: t("SETTINGS$NAV_INTEGRATIONS") },
|
||||
{ to: "/settings/app", text: t("SETTINGS$NAV_APPLICATION") },
|
||||
{ to: "/settings/secrets", text: t("SETTINGS$NAV_SECRETS") },
|
||||
];
|
||||
if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) {
|
||||
// if in OSS mode, do not allow access to saas-only paths
|
||||
return redirect("/settings");
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSaas) {
|
||||
if (pathname === "/settings") {
|
||||
navigate("/settings/user");
|
||||
}
|
||||
} else {
|
||||
const noEnteringPaths = [
|
||||
"/settings/user",
|
||||
"/settings/billing",
|
||||
"/settings/credits",
|
||||
"/settings/api-keys",
|
||||
];
|
||||
if (noEnteringPaths.includes(pathname)) {
|
||||
navigate("/settings");
|
||||
}
|
||||
}
|
||||
}, [isSaas, pathname]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const navItems = isSaas ? saasNavItems : ossNavItems;
|
||||
function SettingsScreen() {
|
||||
const { t } = useTranslation();
|
||||
const { data: config } = useConfig();
|
||||
|
||||
const isSaas = config?.APP_MODE === "saas";
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
return (
|
||||
<main
|
||||
@@ -77,7 +92,7 @@ function SettingsScreen() {
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className="text-[#F9FBFE] text-sm">{text}</span>
|
||||
<span className="text-[#F9FBFE] text-sm">{t(text)}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -45,7 +45,7 @@ function EmailInputSection({
|
||||
isEmailChanged && !isEmailValid
|
||||
? "border-red-500"
|
||||
: "border-tertiary"
|
||||
} flex-grow focus:outline-hidden focus:border-transparent focus:ring-0`}
|
||||
} flex-grow`}
|
||||
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
|
||||
data-testid="email-input"
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "#/types/message";
|
||||
import { handleObservationMessage } from "./observations";
|
||||
import { appendInput } from "#/state/command-slice";
|
||||
import { appendJupyterInput } from "#/state/jupyter-slice";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
|
||||
export function handleActionMessage(message: ActionMessage) {
|
||||
@@ -32,6 +33,10 @@ export function handleActionMessage(message: ActionMessage) {
|
||||
store.dispatch(appendInput(message.args.command));
|
||||
}
|
||||
|
||||
if (message.action === ActionType.RUN_IPYTHON) {
|
||||
store.dispatch(appendJupyterInput(message.args.code));
|
||||
}
|
||||
|
||||
if ("args" in message && "security_risk" in message.args) {
|
||||
store.dispatch(appendSecurityAnalyzerInput(message));
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
describe("Localization Fix Tests", () => {
|
||||
it("should not find any unlocalized strings in the frontend code", () => {
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
|
||||
// Run the localization check script
|
||||
const result = execSync(`node ${scriptPath}`, {
|
||||
cwd: path.join(__dirname, "../.."),
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
// The script should output success message and exit with code 0
|
||||
expect(result).toContain(
|
||||
"✅ No unlocalized strings found in frontend code.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly detect user-facing attributes like placeholder, alt, and aria-label", () => {
|
||||
// This test verifies that our fix to include placeholder, alt, and aria-label
|
||||
// attributes in the localization check is working correctly by testing the regex patterns
|
||||
|
||||
const scriptPath = path.join(
|
||||
__dirname,
|
||||
"../../scripts/check-unlocalized-strings.cjs",
|
||||
);
|
||||
const scriptContent = fs.readFileSync(scriptPath, "utf8");
|
||||
|
||||
// Verify that these attributes are now being checked for localization
|
||||
// by ensuring they're not excluded from text extraction
|
||||
const nonTextAttributesMatch = scriptContent.match(
|
||||
/const NON_TEXT_ATTRIBUTES = \[(.*?)\]/s,
|
||||
);
|
||||
expect(nonTextAttributesMatch).toBeTruthy();
|
||||
|
||||
const nonTextAttributes = nonTextAttributesMatch![1];
|
||||
expect(nonTextAttributes).not.toContain('"placeholder"');
|
||||
expect(nonTextAttributes).not.toContain('"alt"');
|
||||
expect(nonTextAttributes).not.toContain('"aria-label"');
|
||||
|
||||
// Verify that the script contains the correct attributes that should be excluded
|
||||
expect(nonTextAttributes).toContain('"className"');
|
||||
expect(nonTextAttributes).toContain('"testId"');
|
||||
expect(nonTextAttributes).toContain('"href"');
|
||||
});
|
||||
|
||||
it("should not incorrectly flag CSS units as unlocalized strings", () => {
|
||||
// This test verifies that our fix to the CSS units regex pattern
|
||||
// prevents false positives like "Suggested Tasks" being flagged
|
||||
|
||||
const testStrings = [
|
||||
"Suggested Tasks",
|
||||
"No tasks available",
|
||||
"Select a branch",
|
||||
"Select a repo",
|
||||
"Custom Models",
|
||||
"API Keys",
|
||||
"Git Settings",
|
||||
];
|
||||
|
||||
// These strings should not be flagged as CSS units
|
||||
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)$/;
|
||||
|
||||
testStrings.forEach((str) => {
|
||||
expect(cssUnitsPattern.test(str)).toBe(false);
|
||||
});
|
||||
|
||||
// But actual CSS units should still be detected
|
||||
const actualCssUnits = ["10px", "2rem", "100vh", "px", "rem", "s"];
|
||||
actualCssUnits.forEach((unit) => {
|
||||
expect(cssUnitsPattern.test(unit)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ export enum AgentState {
|
||||
}
|
||||
|
||||
export const RUNTIME_INACTIVE_STATES = [
|
||||
AgentState.INIT,
|
||||
AgentState.LOADING,
|
||||
AgentState.STOPPED,
|
||||
AgentState.ERROR,
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
|
||||
}
|
||||
|
||||
export interface SystemMessageAction extends OpenHandsActionEvent<"system"> {
|
||||
source: "agent";
|
||||
source: "agent" | "environment";
|
||||
args: {
|
||||
content: string;
|
||||
tools: Array<Record<string, unknown>> | null;
|
||||
|
||||
@@ -5,4 +5,15 @@ export type RuntimeStatus =
|
||||
| "STATUS$RUNTIME_STARTED"
|
||||
| "STATUS$SETTING_UP_WORKSPACE"
|
||||
| "STATUS$SETTING_UP_GIT_HOOKS"
|
||||
| "STATUS$READY";
|
||||
| "STATUS$READY"
|
||||
| "STATUS$ERROR"
|
||||
| "STATUS$ERROR_RUNTIME_DISCONNECTED"
|
||||
| "STATUS$ERROR_LLM_AUTHENTICATION"
|
||||
| "STATUS$ERROR_LLM_SERVICE_UNAVAILABLE"
|
||||
| "STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR"
|
||||
| "STATUS$ERROR_LLM_OUT_OF_CREDITS"
|
||||
| "STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION"
|
||||
| "CHAT_INTERFACE$AGENT_RATE_LIMITED_STOPPED_MESSAGE"
|
||||
| "STATUS$GIT_PROVIDER_AUTHENTICATION_ERROR"
|
||||
| "STATUS$LLM_RETRY"
|
||||
| "STATUS$ERROR_MEMORY";
|
||||
|
||||
104
frontend/src/utils/__tests__/custom-toast-handlers.test.ts
Normal file
104
frontend/src/utils/__tests__/custom-toast-handlers.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "../custom-toast-handlers";
|
||||
|
||||
// Mock react-hot-toast
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("custom-toast-handlers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("displaySuccessToast", () => {
|
||||
it("should call toast.success with calculated duration for short message", () => {
|
||||
const shortMessage = "Settings saved";
|
||||
displaySuccessToast(shortMessage);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
shortMessage,
|
||||
expect.objectContaining({
|
||||
duration: 5000, // Should use minimum duration of 5000ms
|
||||
position: "top-right",
|
||||
style: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call toast.success with longer duration for long message", () => {
|
||||
const longMessage =
|
||||
"Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.";
|
||||
displaySuccessToast(longMessage);
|
||||
|
||||
expect(toast.success).toHaveBeenCalledWith(
|
||||
longMessage,
|
||||
expect.objectContaining({
|
||||
duration: expect.any(Number),
|
||||
position: "top-right",
|
||||
style: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the actual duration that was passed
|
||||
const callArgs = (
|
||||
toast.success as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls[0][1] as { duration: number };
|
||||
const actualDuration = callArgs.duration;
|
||||
|
||||
// For a long message, duration should be more than the minimum 5000ms
|
||||
expect(actualDuration).toBeGreaterThan(5000);
|
||||
// But should not exceed the maximum 10000ms
|
||||
expect(actualDuration).toBeLessThanOrEqual(10000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayErrorToast", () => {
|
||||
it("should call toast.error with calculated duration for short message", () => {
|
||||
const shortMessage = "Error occurred";
|
||||
displayErrorToast(shortMessage);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
shortMessage,
|
||||
expect.objectContaining({
|
||||
duration: 4000, // Should use minimum duration of 4000ms for errors
|
||||
position: "top-right",
|
||||
style: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call toast.error with longer duration for long error message", () => {
|
||||
const longMessage =
|
||||
"A very long error message that should take more time to read and understand what went wrong with the operation.";
|
||||
displayErrorToast(longMessage);
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
longMessage,
|
||||
expect.objectContaining({
|
||||
duration: expect.any(Number),
|
||||
position: "top-right",
|
||||
style: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
|
||||
// Get the actual duration that was passed
|
||||
const callArgs = (
|
||||
toast.error as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls[0][1] as { duration: number };
|
||||
const actualDuration = callArgs.duration;
|
||||
|
||||
// For a long message, duration should be more than the minimum 4000ms
|
||||
expect(actualDuration).toBeGreaterThan(4000);
|
||||
// But should not exceed the maximum 10000ms
|
||||
expect(actualDuration).toBeLessThanOrEqual(10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
53
frontend/src/utils/__tests__/toast-duration.test.ts
Normal file
53
frontend/src/utils/__tests__/toast-duration.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { calculateToastDuration } from "../toast-duration";
|
||||
|
||||
describe("calculateToastDuration", () => {
|
||||
it("should return minimum duration for short messages", () => {
|
||||
const shortMessage = "OK";
|
||||
const duration = calculateToastDuration(shortMessage, 5000);
|
||||
expect(duration).toBe(5000);
|
||||
});
|
||||
|
||||
it("should return minimum duration for messages that calculate below minimum", () => {
|
||||
const shortMessage = "Settings saved";
|
||||
const duration = calculateToastDuration(shortMessage, 5000);
|
||||
expect(duration).toBe(5000);
|
||||
});
|
||||
|
||||
it("should calculate longer duration for long messages", () => {
|
||||
const longMessage =
|
||||
"Settings saved. For old conversations, you will need to stop and restart the conversation to see the changes.";
|
||||
const duration = calculateToastDuration(longMessage, 5000);
|
||||
expect(duration).toBeGreaterThan(5000);
|
||||
expect(duration).toBeLessThanOrEqual(10000);
|
||||
});
|
||||
|
||||
it("should respect maximum duration cap", () => {
|
||||
const veryLongMessage = "A".repeat(10000); // Very long message
|
||||
const duration = calculateToastDuration(veryLongMessage, 5000, 10000);
|
||||
expect(duration).toBe(10000);
|
||||
});
|
||||
|
||||
it("should use custom minimum and maximum durations", () => {
|
||||
const message = "Test message";
|
||||
const customMin = 3000;
|
||||
const customMax = 8000;
|
||||
const duration = calculateToastDuration(message, customMin, customMax);
|
||||
expect(duration).toBeGreaterThanOrEqual(customMin);
|
||||
expect(duration).toBeLessThanOrEqual(customMax);
|
||||
});
|
||||
|
||||
it("should calculate duration based on reading speed", () => {
|
||||
// Test with a message that should take exactly the calculated time
|
||||
// At 200 WPM (1000 chars/min), 60 chars should take 3.6 seconds
|
||||
// With 1.5x buffer, that's 5.4 seconds
|
||||
const message = "This is a test message that contains exactly sixty chars.";
|
||||
expect(message.length).toBe(57); // Close to 60 chars
|
||||
|
||||
const duration = calculateToastDuration(message, 0, 20000); // No min/max constraints
|
||||
|
||||
// Should be around 5.4 seconds (5400ms) for 57 characters
|
||||
expect(duration).toBeGreaterThan(5000);
|
||||
expect(duration).toBeLessThan(6000);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user