Compare commits

..

71 Commits

Author SHA1 Message Date
openhands 1b206c9727 Fix unlocalized strings in microagent components 2025-06-30 15:18:48 +00:00
amanape b100bb51c9 merge 2025-06-30 19:00:58 +04:00
amanape 3b6b6a10d8 fix tests 2025-06-18 20:48:46 +04:00
openhands 5e6553854e Fix trailing whitespace in test file
- Fixed trailing whitespace issues found by pre-commit hooks
- All pre-commit checks now passing
2025-06-18 16:48:16 +00:00
amanape e8dba65355 merge 2025-06-18 20:23:55 +04:00
amanape 7ebc8be7bb fix lint 2025-06-18 18:18:36 +04:00
sp.wack c827b0dbb8 Merge branch 'main' into ALL-1986/feat/memory 2025-06-18 17:17:52 +04:00
amanape a5207bf8c0 remove error callback 2025-06-17 19:41:30 +04:00
amanape 2b05d4c320 refactor 2025-06-17 19:40:04 +04:00
openhands e5fb016388 feat: Add PR URL detection for microagent finish messages
- Add utility function to parse PR URLs from text with support for GitHub, GitLab, Bitbucket, and Azure DevOps
- Modify microagent status indicator to show 'View your PR' when a PR URL is detected in finish messages
- Update microagent event handler to extract PR URLs from finish actions
- Add comprehensive tests for PR URL parsing and microagent status indicator
- Add internationalization support for 'View your PR' text
- Move tests to frontend/__tests__ directory as per project structure

When a microagent finishes with a PR URL in the final_thought, the status indicator now shows 'View your PR' as a clickable link to the PR instead of the default completion message.
2025-06-17 14:48:09 +00:00
amanape 0519f019c1 fix display in error case 2025-06-17 18:03:26 +04:00
amanape dc569a629c fixl int 2025-06-17 17:08:37 +04:00
amanape c7b71cd092 fix microagents issue 2025-06-17 17:05:26 +04:00
amanape ab15422d77 resolve 2025-06-17 16:34:37 +04:00
amanape 101f40f447 conditional 2025-06-16 19:41:06 +04:00
openhands 0068737636 feat: Add conversation link to microagent status indicator
- Add conversationId prop to MicroagentStatusIndicator component
- Make status text clickable link when status is COMPLETED and conversationId exists
- Link opens microagent conversation in new tab similar to toast behavior
- Pass microagent conversation ID through EventMessage component chain
- Add getMicroagentConversationIdForEvent helper function in Messages component
- Update all MicroagentStatusIndicator usages to pass conversationId

Users can now click on completed microagent status text to view the
microagent conversation directly, providing seamless navigation between
the main conversation and microagent updates.
2025-06-16 14:56:48 +00:00
openhands 2b6e4c4240 feat: Update microagent modal labels and add triggers info icon
- Change textarea label to 'What would you like your microagent to remember?'
- Change triggers label to 'Add triggers for the microagent'
- Add information icon for triggers linking to microagents-keyword docs
- Add new translation keys MICROAGENT$WHAT_TO_REMEMBER and MICROAGENT$ADD_TRIGGERS
- Regenerate i18n declaration file with new keys

The modal now provides clearer guidance on what each field is for and includes
helpful documentation links for users to understand trigger functionality.
2025-06-16 14:43:51 +00:00
openhands 255f910cf6 fix: Remove RUNNING status from microagent status indicator
- Remove MicroagentStatus.RUNNING from component logic
- Remove RUNNING status test case
- Remove MICROAGENT from translation files
- Regenerate i18n declaration file
- Update component to only handle CREATING, COMPLETED, and ERROR states
2025-06-16 14:25:04 +00:00
openhands b34d555206 feat: Add toast auto-dismiss and microagent dropdown loading state
- Update microagent status toasts to auto-dismiss after 5 seconds
- Add loading state to microagent 'Where should we put it?' dropdown
- Update SettingsDropdownInput to support isLoading prop
- Show loading spinner and placeholder text while fetching microagents
- Disable Launch button when microagents are loading
- Fix MicroagentStatusIndicator to handle RUNNING status
- Update tests to match current component implementation

Users now get better feedback during microagent creation with:
- Auto-dismissing toasts that don't require manual dismissal
- Loading indicators while microagent list is being fetched
- Proper disabled states during loading operations
2025-06-16 14:19:28 +00:00
amanape a892cb0cf3 status indicators under events 2025-06-16 18:08:37 +04:00
openhands 5808e5587f feat: Add microagent status indicator for event messages
- Add MicroagentStatus enum with 4 states (creating, running, completed, error)
- Create MicroagentStatusIndicator component with visual feedback
- Update Messages component to track microagent status per event
- Modify EventMessage to show status only on messages with actions
- Add socket event handling for status updates
- Include comprehensive test coverage
- Add i18n translations for all status messages

The status indicator appears under the specific event that triggered
the microagent creation, providing real-time feedback to users about
the progress of their microagent requests.
2025-06-16 13:30:50 +00:00
amanape 5069cb82e8 fix values 2025-06-16 17:04:34 +04:00
amanape 70ce1dd400 refactor 2025-06-16 16:49:20 +04:00
openhands 65abbfa39d Fix socket disconnection issues in ConversationSubscriptionsProvider 2025-06-12 16:36:51 +00:00
amanape 4a879f22d7 refactor 2025-06-12 20:32:52 +04:00
amanape d0db3a8a21 merge 2025-06-12 19:42:39 +04:00
openhands 7afd9ccf93 Add support for multiple conversation subscriptions 2025-06-12 15:28:49 +00:00
amanape 5bea4ab6b7 resolve 2025-06-10 20:24:42 +04:00
amanape 2f819b4f80 remove test 2025-06-09 20:39:07 +04:00
amanape 1284f720ac Better icon 2025-06-09 19:45:08 +04:00
amanape 8374d19b08 Merge branch 'main' into ALL-1986/feat/memory 2025-06-09 19:23:23 +04:00
amanape 5ebae57add Merge branch 'main' into ALL-1986/feat/memory 2025-06-09 17:20:14 +04:00
amanape d7ac6cbf40 refactor 2025-06-06 18:04:46 +04:00
amanape 8cbbc2331f merge 2025-06-06 17:26:07 +04:00
openhands 0f359373c0 Fix launch-microagent-modal tests by mocking the selector hook 2025-06-05 20:11:09 +00:00
openhands 7eebe16d9e Fix unlocalized strings in microagent components 2025-06-05 18:37:13 +00:00
amanape 1fc4c5d856 loading states 2025-06-05 22:21:44 +04:00
amanape 225966e89e Merge branch 'main' into ALL-1986/feat/memory 2025-06-05 21:43:37 +04:00
amanape c66d4fdad8 clear socket ref after job finished 2025-06-05 18:07:19 +04:00
amanape 7b71f786bb refactor 2025-06-04 19:20:57 +04:00
amanape ceac54e767 refactor 2025-06-04 18:32:14 +04:00
amanape b2a93d9d7f refactor 2025-06-04 18:25:08 +04:00
amanape 00ca066656 comment 2025-06-04 17:45:38 +04:00
amanape bf560c2b8f refactor 2025-06-04 17:44:52 +04:00
amanape fd3531223a fix typo 2025-06-04 17:04:34 +04:00
amanape 424c59deb1 fix tessts 2025-06-04 17:01:33 +04:00
amanape 5f036c7011 Merge branch 'main' into ALL-1986/feat/memory 2025-06-04 16:51:49 +04:00
amanape a044ba85e9 failing checks 2025-06-04 13:10:58 +04:00
amanape 55c7cfd293 wip 2025-06-03 20:29:34 +04:00
amanape 7ba81f952f WIP 2025-06-03 16:56:27 +04:00
amanape df9f3b2b2b merge 2025-06-02 16:42:36 +04:00
amanape 7f3dd754c3 wip 2025-06-02 16:41:35 +04:00
tofarr 28f4f8f93d Allowing local runtimes to have domains (#8798) 2025-05-30 21:20:23 +04:00
Rohit Malhotra 68eb51eeab [Fix]: inconsistent microagent descriptions (#8800) 2025-05-30 21:20:23 +04:00
Robert Brennan ca82a3988f add more logging to debug runtime restarts (#8799) 2025-05-30 21:20:23 +04:00
Engel Nyst 4c2039be7e Rename service (#8791) 2025-05-30 21:20:23 +04:00
tofarr 94cbf98771 Fix openapi authorize (#8794) 2025-05-30 21:20:23 +04:00
sp.wack 326651c339 Add git_provider and selected_branch to conversation response (#8795)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-05-30 21:20:23 +04:00
Engel Nyst 9b2180ec4f Merge branch 'main' of github.com:All-Hands-AI/OpenHands into ALL-1986/feat/memory 2025-05-29 20:41:48 +02:00
amanape 154d18911f small refactor 2025-05-29 17:29:03 +04:00
amanape 05a8c1cf4c merge 2025-05-29 17:05:22 +04:00
amanape 4f567e390a wip 2025-05-22 23:07:15 +04:00
amanape afc5a41aea refine 2025-05-22 20:44:29 +04:00
amanape effefa3d56 merge 2025-05-22 19:10:08 +04:00
amanape cfb4b400a3 Badge input 2025-05-21 23:09:09 +04:00
amanape fe669bef45 Display toasts of status of new conversation 2025-05-21 19:58:59 +04:00
amanape 429d9100a2 Merge branch 'main' into ALL-1986/feat/memory 2025-05-21 19:08:47 +04:00
openhands ebc075d5ab Fix socket.io event handling in useSubscribeToConversation and useSocketIO hooks 2025-05-21 15:05:07 +00:00
amanape 3363c6aeb4 refactor 2025-05-21 18:25:56 +04:00
amanape de99873f66 wip 2025-05-21 17:56:19 +04:00
amanape d5b3e83d66 Initial commit 2025-05-20 18:02:24 +04:00
238 changed files with 2670 additions and 20150 deletions
-1
View File
@@ -3,7 +3,6 @@
# Frontend code owners
/frontend/ @rbren @amanape
/openhands-ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
+4 -4
View File
@@ -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: 22
node-version: [20, 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 build
run: npm run make-i18n && tsc
- name: Run tests and collect coverage
working-directory: ./frontend
run: npm run test:coverage
-2
View File
@@ -54,7 +54,6 @@ 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
@@ -104,7 +103,6 @@ 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
+3 -3
View File
@@ -21,10 +21,10 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Node.js 22
- name: Install Node.js 20
uses: useblacksmith/setup-node@v5
with:
node-version: 22
node-version: 20
- 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
+4 -4
View File
@@ -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 22
- name: Install Node.js 20
uses: useblacksmith/setup-node@v5
with:
node-version: 22
node-version: 20
- 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
+1 -1
View File
@@ -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: 'roadmap'
exempt-issue-labels: 'tracked'
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
@@ -1,156 +0,0 @@
# 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
View File
@@ -182,8 +182,6 @@ cython_debug/
.roo/rules
.cline/rules
.windsurf/rules
.repomix
repomix-output.txt
# evaluation
evaluation/evaluation_outputs
-19
View File
@@ -15,13 +15,10 @@ 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.
@@ -63,22 +60,6 @@ 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`.
+1 -1
View File
@@ -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 #eng-ui-ux channel in our Slack
of the application, please open an issue first, or better, join the #frontend channel in our Slack
to gather consensus from our design team first.
#### Improving the agent
+1 -1
View File
@@ -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.48-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
## Develop inside Docker container
+3 -3
View File
@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **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.
+3 -3
View File
@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **注意**: 如果您在0.44版本之前使用过OpenHands,您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。
+3 -3
View File
@@ -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.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。
-114
View File
@@ -1,114 +0,0 @@
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.')
+3 -7
View File
@@ -18,6 +18,9 @@
# 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
@@ -46,9 +49,6 @@
# 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,9 +116,6 @@ 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
@@ -229,7 +226,6 @@ 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
+1 -1
View File
@@ -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.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.47-nikolaik}
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
ports:
+1 -1
View File
@@ -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.48-nikolaik}
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.47-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:
-6
View File
@@ -8,12 +8,6 @@ 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.
+2 -41
View File
@@ -33,45 +33,6 @@ 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
@@ -103,7 +64,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.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik \
-e SANDBOX_USER_ID=$(id -u) \
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
-e LLM_API_KEY=$LLM_API_KEY \
@@ -112,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.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.cli.main --override-cli-mode true
```
+4 -2
View File
@@ -122,15 +122,17 @@ OpenHands automatically exports a `GITLAB_TOKEN` to the shell environment if pro
</Accordion>
</AccordionGroup>
#### BitBucket Setup
#### BitBucket Setup (Coming soon ...)
<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:
- `account`: `read`
- `repository: 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**:
+3 -3
View File
@@ -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.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
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.
+4 -25
View File
@@ -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.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
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.48
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.47
Starting OpenHands...
Running OpenHands as root
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
@@ -175,27 +175,6 @@ 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
+3 -3
View File
@@ -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.48-nikolaik
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.47-nikolaik
docker run -it --rm --pull=always \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.48-nikolaik \
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.47-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.48
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **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.
-9
View File
@@ -24,12 +24,3 @@ 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,21 +38,6 @@ On initial prompt, an error is seen with `Permission Denied` or `PermissionError
* If mounting a local directory, ensure your `WORKSPACE_BASE` has the necessary permissions for the user running
OpenHands.
### 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**
+3 -1
View File
@@ -109,7 +109,9 @@ def get_instruction(instance: pd.Series, metadata: EvalMetadata) -> MessageActio
template_name = 'swt.j2'
elif mode == 'swe':
if 'claude' in llm_model:
template_name = 'swe_default.j2'
template_name = 'swe_claude.j2'
elif 'gemini' in llm_model:
template_name = 'swe_gemini.j2'
elif 'gpt-4.1' in llm_model:
template_name = 'swe_gpt4.j2'
else:
+1 -2
View File
@@ -13,9 +13,8 @@
"plugin:react-hooks/recommended",
"plugin:@tanstack/query/recommended",
],
"plugins": ["prettier", "unused-imports", "i18next"],
"plugins": ["prettier", "unused-imports"],
"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 -1
View File
@@ -1,7 +1,7 @@
# Run frontend checks
echo "Running frontend checks..."
cd frontend
npm run lint
npm run check-unlocalized-strings
npm run check-translation-completeness
npx lint-staged
@@ -5,7 +5,6 @@ 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({
@@ -33,31 +32,6 @@ 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,9 +82,7 @@ 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);
});
@@ -110,11 +108,7 @@ describe("ConversationCard", () => {
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
selectedRepository="org/selectedRepository"
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -179,11 +173,7 @@ describe("ConversationCard", () => {
isActive
onChangeTitle={onChangeTitle}
title="Conversation 1"
selectedRepository={{
selected_repository: "org/selectedRepository",
selected_branch: "main",
git_provider: "github",
}}
selectedRepository="org/selectedRepository"
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
@@ -295,238 +295,4 @@ 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,48 +119,18 @@ describe("RepoConnector", () => {
expect(launchButton).toBeEnabled();
});
it("should render the 'add github repos' link if saas mode and github provider is set", async () => {
it("should render the 'add git(hub|lab) repos' links if saas mode", 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,6 +257,8 @@ describe("RepositorySelectionForm", () => {
expect(searchedRepo).toBeInTheDocument();
await userEvent.click(searchedRepo);
expect(mockOnRepoSelection).toHaveBeenCalledWith(MOCK_SEARCH_REPOS[0]);
expect(mockOnRepoSelection).toHaveBeenCalledWith(
MOCK_SEARCH_REPOS[0].full_name,
);
});
});
@@ -1,84 +0,0 @@
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,12 +21,7 @@ describe("UserActions", () => {
});
it("should toggle the user menu when the user avatar is clicked", async () => {
render(
<UserActions
onLogout={onLogoutMock}
user={{ avatar_url: "https://example.com/avatar.png" }}
/>,
);
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
@@ -62,102 +57,15 @@ describe("UserActions", () => {
).not.toBeInTheDocument();
});
it("should NOT show context menu when user is undefined and avatar is clicked", async () => {
test("logout button is always enabled", async () => {
render(<UserActions onLogout={onLogoutMock} />);
const userAvatar = screen.getByTestId("user-avatar");
await user.click(userAvatar);
// Context menu should NOT appear because user is undefined
expect(
screen.queryByTestId("account-settings-context-menu"),
).not.toBeInTheDocument();
});
const logoutOption = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
await user.click(logoutOption);
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();
expect(onLogoutMock).toHaveBeenCalledOnce();
});
});
@@ -1,140 +0,0 @@
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();
});
});
+22 -53
View File
@@ -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 { QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen, { clientLoader } from "#/routes/settings";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsScreen from "#/routes/settings";
import OpenHands from "#/api/open-hands";
// Mock the i18next hook
@@ -31,27 +31,16 @@ vi.mock("react-i18next", async () => {
});
describe("Settings Screen", () => {
const { handleLogoutMock, mockQueryClient } = vi.hoisted(() => ({
const { handleLogoutMock } = 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: [
{
@@ -67,8 +56,8 @@ describe("Settings Screen", () => {
path: "/settings/app",
},
{
Component: () => <div data-testid="billing-settings-screen" />,
path: "/settings/billing",
Component: () => <div data-testid="credits-settings-screen" />,
path: "/settings/credits",
},
{
Component: () => <div data-testid="api-keys-settings-screen" />,
@@ -78,27 +67,26 @@ describe("Settings Screen", () => {
},
]);
const renderSettingsScreen = (path = "/settings") =>
render(<RouterStub initialEntries={[path]} />, {
const renderSettingsScreen = (path = "/settings") => {
const queryClient = new QueryClient();
return render(<RouterStub initialEntries={[path]} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={mockQueryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
};
it("should render the navbar", async () => {
const sectionsToInclude = ["llm", "integrations", "application", "secrets"];
const sectionsToExclude = ["api keys", "credits", "billing"];
const sectionsToExclude = ["api keys", "credits"];
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");
@@ -114,8 +102,6 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should render the saas navbar", async () => {
@@ -127,15 +113,12 @@ describe("Settings Screen", () => {
const sectionsToInclude = [
"integrations",
"application",
"credits", // The nav item shows "credits" text but routes to /billing
"credits",
"secrets",
"api keys",
];
const sectionsToExclude = ["llm"];
// Clear any existing query data
mockQueryClient.clear();
renderSettingsScreen();
const navbar = await screen.findByTestId("settings-navbar");
@@ -151,44 +134,30 @@ describe("Settings Screen", () => {
});
expect(sectionElement).not.toBeInTheDocument();
});
getConfigSpy.mockRestore();
});
it("should not be able to access saas-only routes in oss mode", async () => {
it("should not be able to access oss-restricted routes in oss", async () => {
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();
// 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();
const { rerender } = renderSettingsScreen("/settings/credits");
expect(
within(navbar).queryByText("credits", { exact: false }),
screen.queryByTestId("credits-settings-screen"),
).not.toBeInTheDocument();
// 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/api-keys"]} />);
expect(
screen.queryByTestId("api-keys-settings-screen"),
).not.toBeInTheDocument();
getConfigSpy.mockRestore();
rerender(<RouterStub initialEntries={["/settings/billing"]} />);
expect(
screen.queryByTestId("billing-settings-screen"),
).not.toBeInTheDocument();
rerender(<RouterStub initialEntries={["/settings"]} />);
});
it.todo("should not be able to access oss-only routes in saas mode");
it.todo("should not be able to access saas-restricted routes in saas");
});
@@ -1,95 +0,0 @@
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();
});
});
+1127 -1338
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,13 +1,13 @@
{
"name": "openhands-frontend",
"version": "0.48.0",
"version": "0.47.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
"node": ">=20.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.0-beta.13",
"@heroui/react": "^2.8.0-beta.10",
"@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.23.0",
"i18next": "^25.3.2",
"framer-motion": "^12.19.2",
"i18next": "^25.2.1",
"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.257.0",
"posthog-js": "^1.255.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
"react-hot-toast": "^2.5.1",
"react-i18next": "^15.6.0",
"react-i18next": "^15.5.3",
"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.3",
"vite": "^7.0.0",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -70,6 +70,7 @@
"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": {
@@ -79,11 +80,11 @@
]
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/parser": "^7.27.7",
"@babel/traverse": "^7.27.7",
"@babel/types": "^7.27.0",
"@mswjs/socket.io-binding": "^0.2.0",
"@playwright/test": "^1.53.2",
"@playwright/test": "^1.53.1",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
@@ -91,7 +92,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.12",
"@types/node": "^24.0.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,7 +107,6 @@
"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.3.0",
"stripe": "^18.2.1",
"tailwindcss": "^4.1.8",
"typescript": "^5.8.3",
"vite-plugin-svgr": "^4.2.0",
+740
View File
@@ -0,0 +1,740 @@
#!/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);
}
-6
View File
@@ -71,12 +71,6 @@ 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 = 8,
maxRows = 16,
onSubmit,
onStop,
onChange,
@@ -1,164 +0,0 @@
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,7 +10,6 @@ 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";
@@ -32,7 +31,6 @@ 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,
@@ -79,26 +77,11 @@ 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,
originalImages: File[],
originalFiles: File[],
images: File[],
files: 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(
@@ -114,16 +97,6 @@ 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);
@@ -194,12 +167,9 @@ export function ChatInterface() {
return (
<ScrollProvider value={scrollProviderValue}>
<div className="h-full flex flex-col justify-between">
{!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 */}
{events.length === 0 && !optimisticUserMessage && (
<ChatSuggestions onSuggestionsClick={setMessageToSend} />
)}
<div
ref={scrollRef}
@@ -222,7 +192,7 @@ export function ChatInterface() {
)}
{isWaitingForUserInput &&
hasSubstantiveAgentActions &&
events.length > 0 &&
!optimisticUserMessage && (
<ActionSuggestions
onSuggestionsClick={(value) => handleSendMessage(value, [], [])}
@@ -12,10 +12,7 @@ export function ChatSuggestions({ onSuggestionsClick }: ChatSuggestionsProps) {
const { t } = useTranslation();
return (
<div
data-testid="chat-suggestions"
className="flex flex-col gap-6 h-full px-4 items-center justify-center"
>
<div 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,8 +5,6 @@ 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;
@@ -29,20 +27,14 @@ export function InteractiveChatBox({
const [files, setFiles] = React.useState<File[]>([]);
const handleUpload = (selectedFiles: File[]) => {
// 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]);
setFiles((prevFiles) => [
...prevFiles,
...selectedFiles.filter((f) => !isFileImage(f)),
]);
setImages((prevImages) => [
...prevImages,
...selectedFiles.filter((f) => isFileImage(f)),
]);
};
const removeElementByIndex = (array: Array<File>, index: number) => {
@@ -1,5 +1,6 @@
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 {
@@ -22,7 +23,6 @@ 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,21 +208,15 @@ export const Messages: React.FC<MessagesProps> = React.memo(
message.id,
)}
microagentPRUrl={getMicroagentPRUrlForEvent(message.id)}
actions={
conversation?.selected_repository
? [
{
icon: (
<MemoryIcon className="w-[14px] h-[14px] text-white" />
),
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
},
]
: undefined
}
actions={[
{
icon: <FaBrain className="w-[14px] h-[14px]" />,
onClick: () => {
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
},
]}
isInLast10Actions={messages.length - 1 - index < 10}
/>
))}
@@ -4,7 +4,6 @@ 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;
@@ -30,11 +29,7 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
showOptions
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={{
selected_repository: conversation?.selected_repository ?? null,
selected_branch: conversation?.selected_branch ?? null,
git_provider: (conversation?.git_provider as Provider) ?? null,
}}
selectedRepository={conversation?.selected_repository ?? null}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
/>
@@ -17,12 +17,8 @@ export function BudgetUsageText({
return (
<div className="flex justify-end">
<span className="text-xs text-neutral-400">
{t(I18nKey.CONVERSATION$BUDGET_USAGE_FORMAT, {
currentCost: `$${currentCost.toFixed(4)}`,
maxBudget: `$${maxBudget.toFixed(4)}`,
usagePercentage: usagePercentage.toFixed(2),
used: t(I18nKey.CONVERSATION$USED),
})}
${currentCost.toFixed(4)} / ${maxBudget.toFixed(4)} (
{usagePercentage.toFixed(2)}% {t(I18nKey.CONVERSATION$USED)})
</span>
</div>
);
@@ -1,57 +0,0 @@
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,7 +8,6 @@ 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;
@@ -20,7 +19,6 @@ interface ConversationCardContextMenuProps {
export function ConversationCardContextMenu({
onClose,
onDelete,
onStop,
onEdit,
onDisplayCost,
onShowAgentTools,
@@ -43,17 +41,12 @@ export function ConversationCardContextMenu({
>
{onDelete && (
<ContextMenuListItem testId="delete-button" onClick={onDelete}>
{t(I18nKey.BUTTON$DELETE)}
</ContextMenuListItem>
)}
{onStop && (
<ContextMenuListItem testId="stop-button" onClick={onStop}>
{t(I18nKey.BUTTON$STOP)}
Delete
</ContextMenuListItem>
)}
{onEdit && (
<ContextMenuListItem testId="edit-button" onClick={onEdit}>
{t(I18nKey.BUTTON$EDIT_TITLE)}
Edit Title
</ContextMenuListItem>
)}
{onDownloadViaVSCode && (
@@ -61,7 +54,7 @@ export function ConversationCardContextMenu({
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
{t(I18nKey.BUTTON$DOWNLOAD_VIA_VSCODE)}
Download via VS Code
</ContextMenuListItem>
)}
{onDisplayCost && (
@@ -69,7 +62,7 @@ export function ConversationCardContextMenu({
testId="display-cost-button"
onClick={onDisplayCost}
>
{t(I18nKey.BUTTON$DISPLAY_COST)}
Display Cost
</ContextMenuListItem>
)}
{onShowAgentTools && (
@@ -77,7 +70,7 @@ export function ConversationCardContextMenu({
testId="show-agent-tools-button"
onClick={onShowAgentTools}
>
{t(I18nKey.BUTTON$SHOW_AGENT_TOOLS_AND_METADATA)}
Show Agent Tools & Metadata
</ContextMenuListItem>
)}
{onShowMicroagents && (
@@ -19,17 +19,15 @@ 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: RepositorySelection | null;
selectedRepository: string | null;
lastUpdatedAt: string; // ISO 8601
createdAt?: string; // ISO 8601
conversationStatus?: ConversationStatus;
@@ -42,7 +40,6 @@ const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
export function ConversationCard({
onClick,
onDelete,
onStop,
onChangeTitle,
showOptions,
isActive,
@@ -104,13 +101,6 @@ 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();
@@ -181,7 +171,7 @@ export function ConversationCard({
data-testid="conversation-card"
onClick={onClick}
className={cn(
"h-auto w-full px-[18px] py-4 border-b border-neutral-600 cursor-pointer",
"h-[100px] 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]",
)}
@@ -234,11 +224,6 @@ export function ConversationCard({
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
? onStop && handleStop
: undefined
}
onEdit={onChangeTitle && handleEdit}
onDownloadViaVSCode={
conversationId && showOptions
@@ -265,33 +250,28 @@ export function ConversationCard({
<div
className={cn(
variant === "compact" && "flex flex-col justify-between mt-1",
variant === "compact" && "flex items-center justify-between mt-1",
)}
>
{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>
{selectedRepository && (
<ConversationRepoLink selectedRepository={selectedRepository} />
)}
<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>
@@ -330,15 +310,11 @@ export function ConversationCard({
</div>
<div className="grid grid-cols-2 gap-2 pl-4 text-sm">
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_HIT)}
</span>
<span className="text-neutral-400">Cache Hit:</span>
<span className="text-right">
{metrics.usage.cache_read_tokens.toLocaleString()}
</span>
<span className="text-neutral-400">
{t(I18nKey.CONVERSATION$CACHE_WRITE)}
</span>
<span className="text-neutral-400">Cache Write:</span>
<span className="text-right">
{metrics.usage.cache_write_tokens.toLocaleString()}
</span>
@@ -5,13 +5,10 @@ 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;
@@ -25,8 +22,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
@@ -38,18 +33,12 @@ 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(
@@ -65,21 +54,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
};
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
}
},
},
);
}
};
return (
<div
ref={ref}
@@ -113,13 +87,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
title={project.title}
selectedRepository={{
selected_repository: project.selected_repository,
selected_branch: project.selected_branch,
git_provider: project.git_provider as Provider,
}}
selectedRepository={project.selected_repository}
lastUpdatedAt={project.last_updated_at}
createdAt={project.created_at}
conversationStatus={project.status}
@@ -139,16 +108,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
/>
)}
{confirmStopModalVisible && (
<ConfirmStopModal
onConfirm={() => {
handleConfirmStop();
setConfirmStopModalVisible(false);
}}
onCancel={() => setConfirmStopModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {
@@ -1,44 +1,16 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { RepositorySelection } from "#/api/open-hands.types";
interface ConversationRepoLinkProps {
selectedRepository: RepositorySelection;
variant: "compact" | "default";
selectedRepository: string;
}
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 (
<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>
<span
data-testid="conversation-card-selected-repository"
className="text-xs text-neutral-400"
>
{selectedRepository}
</span>
);
}
@@ -1,15 +1,11 @@
import { useState } from "react";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { ChevronDown, ChevronRight, RefreshCw } from "lucide-react";
import { ChevronDown, ChevronRight } 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;
@@ -17,16 +13,14 @@ 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) => {
@@ -36,10 +30,6 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
}));
};
const isAgentReady = ![AgentState.LOADING, AgentState.INIT].includes(
curAgentState,
);
return (
<ModalBackdrop onClose={onClose}>
<ModalBody
@@ -48,40 +38,10 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
testID="microagents-modal"
>
<div className="flex flex-col gap-6 w-full">
<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>
<BaseModalTitle title={t(I18nKey.MICROAGENTS_MODAL$TITLE)} />
</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" />
@@ -89,7 +49,6 @@ 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">
@@ -100,81 +59,75 @@ export function MicroagentsModal({ onClose }: MicroagentsModalProps) {
</div>
)}
{!isLoading &&
isAgentReady &&
microagents &&
microagents.length > 0 && (
<div className="p-2 space-y-3">
{microagents.map((agent) => {
const isExpanded = expandedAgents[agent.name] || false;
{!isLoading && 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"
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"
>
<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">
<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="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>
<div className="mt-2">
{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$CONTENT)}
{t(I18nKey.MICROAGENTS_MODAL$TRIGGERS)}
</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 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$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,10 +4,9 @@ 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: (repo: GitRepository | null) => void;
onRepoSelection: (repoTitle: string | null) => void;
}
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
@@ -1,26 +1,20 @@
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">
{hasGithubProvider && (
<a href={githubHref} target="_blank" rel="noopener noreferrer">
{t(I18nKey.HOME$ADD_GITHUB_REPOS)}
</a>
)}
<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: (repo: GitRepository | null) => void;
onRepoSelection: (repoTitle: string | null) => void;
}
export function RepositorySelectionForm({
@@ -96,7 +96,8 @@ export function RepositorySelectionForm({
const handleRepoSelection = (key: React.Key | null) => {
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
if (selectedRepo) onRepoSelection(selectedRepo);
if (selectedRepo) onRepoSelection(selectedRepo.full_name);
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 last:border-b-0">
<li className="py-3 border-b border-[#717888] flex items-center pr-6">
<TaskIssueNumber issueNumber={task.issue_number} href={href} />
<div className="w-full pl-8">
@@ -1,4 +1,3 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { TaskCard } from "./task-card";
import { TaskItemTitle } from "./task-item-title";
import { SuggestedTask } from "./task.types";
@@ -9,16 +8,9 @@ interface TaskGroupProps {
}
export function TaskGroup({ title, tasks }: TaskGroupProps) {
const gitProvider = tasks.length > 0 ? tasks[0].git_provider : null;
return (
<div className="text-content-2">
<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>
<TaskItemTitle>{title}</TaskItemTitle>
<ul className="text-sm">
{tasks.map((task) => (
@@ -1,6 +1,6 @@
export function TaskItemTitle({ children: title }: React.PropsWithChildren) {
return (
<div className="py-3">
<div className="py-3 border-b-1 border-[#717888]">
<h3 className="text-[16px] leading-6 font-[500]">{title}</h3>
</div>
);
@@ -6,24 +6,16 @@ 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?: GitRepository | null;
filterFor?: string | null;
}
export function TaskSuggestions({ filterFor }: TaskSuggestionsProps) {
const { t } = useTranslation();
const { data: tasks, isLoading } = useSuggestedTasks();
const suggestedTasks = filterFor
? tasks?.filter(
(element) =>
element.title === filterFor.full_name &&
!!element.tasks.find(
(task) => task.git_provider === filterFor.git_provider,
),
)
? tasks?.filter((task) => task.title === filterFor)
: tasks;
const hasSuggestedTasks = suggestedTasks && suggestedTasks.length > 0;
@@ -1,11 +1,9 @@
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;
@@ -13,43 +11,28 @@ 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 (
<>
{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 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>
)}
{!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>
)}
</>
</div>
);
}
@@ -1,6 +1,5 @@
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";
@@ -109,12 +108,7 @@ export function ApiKeysManager() {
<tbody>
{apiKeys.map((key) => (
<tr key={key.id} className="border-t border-tertiary">
<td
className="p-3 text-sm truncate max-w-[160px]"
title={key.name}
>
{key.name}
</td>
<td className="p-3 text-sm">{key.name}</td>
<td className="p-3 text-sm">
{formatDate(key.created_at)}
</td>
@@ -124,14 +118,13 @@ 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"
>
<FaTrash size={16} />
{t(I18nKey.BUTTON$DELETE)}
</button>
</td>
</tr>
@@ -73,7 +73,7 @@ export function DeleteApiKeyModal({
footer={modalFooter}
>
<div data-testid="delete-api-key-modal">
<p className="text-sm break-all">
<p className="text-sm">
{t(I18nKey.SETTINGS$DELETE_API_KEY_CONFIRMATION, {
name: keyToDelete.name,
})}
@@ -0,0 +1,41 @@
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()}
>
{t(I18nKey.COMMON$DOCUMENTATION)}
Documentation
</a>
<BrandButton
type="button"
@@ -1,11 +1,3 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export function OptionalTag() {
const { t } = useTranslation();
return (
<span className="text-xs text-tertiary-alt">
{t(I18nKey.COMMON$OPTIONAL)}
</span>
);
return <span className="text-xs text-tertiary-alt">(Optional)</span>;
}
@@ -1,7 +1,6 @@
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";
@@ -152,7 +151,7 @@ export function SecretForm({
{mode === "add" && (
<label className="flex flex-col gap-2.5 w-full max-w-[680px]">
<span className="text-sm">{t(I18nKey.FORM$VALUE)}</span>
<span className="text-sm">Value</span>
<textarea
data-testid="value-input"
name="secret-value"
@@ -169,7 +168,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">{t(I18nKey.FORM$DESCRIPTION)}</span>
<span className="text-sm">Description</span>
<OptionalTag />
</div>
<input
@@ -191,7 +190,7 @@ export function SecretForm({
variant="secondary"
onClick={onCancel}
>
{t(I18nKey.BUTTON$CANCEL)}
Cancel
</BrandButton>
<BrandButton testId="submit-button" type="submit" variant="primary">
{mode === "add" && t("SECRETS$ADD_SECRET")}
@@ -32,26 +32,20 @@ export function SecretListItem({
return (
<tr
data-testid="secret-item"
className="flex w-full items-center border-t border-tertiary"
className="border-t border-[#717888] last-of-type:border-b max-w-[830px] py-[13px] flex w-full items-center"
>
<td className="p-3 w-1/4 text-sm text-content-2 truncate" title={title}>
{title}
<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>
<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">
<td className="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>
@@ -60,7 +54,6 @@ export function SecretListItem({
type="button"
onClick={onDelete}
aria-label={`Delete ${title}`}
className="cursor-pointer"
>
<FaTrash size={16} />
</button>
@@ -1,6 +1,4 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { StyledSwitchComponent } from "./styled-switch-component";
interface SettingsSwitchProps {
@@ -21,7 +19,6 @@ export function SettingsSwitch({
isToggled: controlledIsToggled,
isBeta,
}: React.PropsWithChildren<SettingsSwitchProps>) {
const { t } = useTranslation();
const [isToggled, setIsToggled] = React.useState(defaultIsToggled ?? false);
const handleToggle = (value: boolean) => {
@@ -47,7 +44,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">
{t(I18nKey.BADGE$BETA)}
Beta
</span>
)}
</div>
@@ -33,7 +33,7 @@ export function UserActions({ onLogout, user, isLoading }: UserActionsProps) {
isLoading={isLoading}
/>
{accountContextMenuIsVisible && !!user && (
{accountContextMenuIsVisible && (
<AccountSettingsContextMenu
onLogout={handleLogout}
onClose={closeAccountMenu}
@@ -22,14 +22,7 @@ function Terminal() {
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
</div>
)}
<div
ref={ref}
className={
isRuntimeInactive
? "w-0 h-0 opacity-0 overflow-hidden"
: "h-full w-full"
}
/>
<div ref={ref} className="h-full w-full" />
</div>
);
}
@@ -20,12 +20,13 @@ export function ActionButton({
<button
onClick={() => handleAction(action)}
disabled={isDisabled}
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"
className="relative overflow-visible cursor-default hover:cursor-pointer group disabled:cursor-not-allowed transition-all duration-300 ease-in-out"
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,5 +1,3 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { BrandButton } from "#/components/features/settings/brand-button";
import { ModalBackdrop } from "./modal-backdrop";
@@ -14,7 +12,6 @@ export function ConfirmationModal({
onConfirm,
onCancel,
}: ConfirmationModalProps) {
const { t } = useTranslation();
return (
<ModalBackdrop onClose={onCancel}>
<div
@@ -30,7 +27,7 @@ export function ConfirmationModal({
variant="secondary"
className="grow"
>
{t(I18nKey.BUTTON$CANCEL)}
Cancel
</BrandButton>
<BrandButton
testId="confirm-button"
@@ -39,7 +36,7 @@ export function ConfirmationModal({
variant="primary"
className="grow"
>
{t(I18nKey.BUTTON$CONFIRM)}
Confirm
</BrandButton>
</div>
</div>
@@ -85,6 +85,12 @@ 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();
@@ -97,6 +103,8 @@ 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];
@@ -105,11 +113,20 @@ 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
@@ -144,9 +161,13 @@ 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) {
@@ -211,6 +232,7 @@ 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;
@@ -267,7 +289,15 @@ 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];
}
@@ -1,31 +0,0 @@
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,13 +1,9 @@
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"],
@@ -18,10 +14,7 @@ export const useConversationMicroagents = () => {
const data = await OpenHands.getMicroagents(conversationId);
return data.microagents;
},
enabled:
!!conversationId &&
curAgentState !== AgentState.LOADING &&
curAgentState !== AgentState.INIT,
enabled: !!conversationId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -1,7 +1,6 @@
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;
@@ -11,7 +10,6 @@ export interface FeedbackData {
export const useFeedbackExists = (eventId?: number) => {
const { conversationId } = useConversationId();
const { data: config } = useConfig();
return useQuery<FeedbackData>({
queryKey: ["feedback", "exists", conversationId, eventId],
@@ -19,7 +17,7 @@ export const useFeedbackExists = (eventId?: number) => {
if (!eventId) return { exists: false };
return OpenHands.checkFeedbackExists(conversationId, eventId);
},
enabled: !!eventId && config?.APP_MODE === "saas",
enabled: !!eventId,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
-22
View File
@@ -118,9 +118,6 @@ 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",
@@ -283,7 +280,6 @@ 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",
@@ -315,18 +311,12 @@ 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",
@@ -376,7 +366,6 @@ 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",
@@ -386,7 +375,6 @@ 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",
@@ -595,7 +583,6 @@ 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",
@@ -664,13 +651,4 @@ 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",
}
+1 -1
View File
@@ -27,7 +27,7 @@ i18n
.init({
fallbackLng: "en",
debug: import.meta.env.NODE_ENV === "development",
load: "currentOnly",
load: "languageOnly",
});
export default i18n;
+15 -367
View File
@@ -1888,54 +1888,6 @@
"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": "アカウント設定",
@@ -4449,7 +4401,7 @@
"uk": "Зупинено"
},
"CHAT_INTERFACE$INITIALIZING_AGENT_LOADING_MESSAGE": {
"en": "Initializing agent...",
"en": "Initializing Agent...",
"de": "Agent wird initialisiert...",
"zh-CN": "正在初始化智能体...",
"zh-TW": "正在初始化智能體...",
@@ -4513,36 +4465,20 @@
"uk": "Агент очікує на введення даних від користувача..."
},
"CHAT_INTERFACE$AGENT_RATE_LIMITED_MESSAGE": {
"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": "Агента обмежено кількістю запитів. Зупинено."
"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": "Агента обмежено кількістю запитів"
},
"CHAT_INTERFACE$AGENT_PAUSED_MESSAGE": {
"en": "Agent has paused.",
@@ -5040,70 +4976,6 @@
"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": "画像を添付",
@@ -5200,38 +5072,6 @@
"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": "会話メトリクス",
@@ -6016,22 +5856,6 @@
"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": "設定がリセットされました",
@@ -6176,22 +6000,6 @@
"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": "Префікс ключа",
@@ -9520,22 +9328,6 @@
"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": "トリガー",
@@ -10623,149 +10415,5 @@
"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
View File
@@ -1,24 +0,0 @@
<?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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+7 -6
View File
@@ -4,15 +4,14 @@ 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 [selectedRepo, setSelectedRepo] = React.useState<GitRepository | null>(
null,
);
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
string | null
>(null);
const providersAreSet = providers.length > 0;
@@ -26,9 +25,11 @@ function HomeScreen() {
<hr className="border-[#717888]" />
<main className="flex flex-col lg:flex-row justify-between gap-8">
<RepoConnector onRepoSelection={(repo) => setSelectedRepo(repo)} />
<RepoConnector
onRepoSelection={(title) => setSelectedRepoTitle(title)}
/>
<hr className="md:hidden border-[#717888]" />
{providersAreSet && <TaskSuggestions filterFor={selectedRepo} />}
{providersAreSet && <TaskSuggestions filterFor={selectedRepoTitle} />}
</main>
</div>
);
+2 -23
View File
@@ -7,36 +7,15 @@ 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) {
resizeObserver.observe(parentRef.current);
setParentWidth(parentRef.current.offsetWidth);
}
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={maxWidth} />
<JupyterEditor maxWidth={parentWidth} />
</div>
);
}
+1 -1
View File
@@ -75,7 +75,7 @@ function LlmSettingsScreen() {
}, [settings, resources]);
const handleSuccessfulMutation = () => {
displaySuccessToast(t(I18nKey.SETTINGS$SAVED_WARNING));
displaySuccessToast(t(I18nKey.SETTINGS$SAVED));
setDirtyInputs({
model: false,
apiKey: false,
+22 -37
View File
@@ -100,6 +100,28 @@ 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"
@@ -112,43 +134,6 @@ 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"}
+1 -1
View File
@@ -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">
{t(I18nKey.BROWSER$SERVER_MESSAGE)}
If you tell OpenHands to start a web server, the app will appear here.
</span>
</div>
);
+42 -57
View File
@@ -1,70 +1,55 @@
import { NavLink, Outlet, redirect } from "react-router";
import { NavLink, Outlet, useLocation, useNavigate } 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";
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";
if (isSaas && pathname === "/settings") {
// no llm settings in saas mode, so redirect to user settings
return redirect("/settings/user");
}
if (!isSaas && SAAS_ONLY_PATHS.includes(pathname)) {
// if in OSS mode, do not allow access to saas-only paths
return redirect("/settings");
}
return null;
};
function SettingsScreen() {
const { t } = useTranslation();
const navigate = useNavigate();
const { pathname } = useLocation();
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;
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") },
];
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") },
];
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]);
const navItems = isSaas ? saasNavItems : ossNavItems;
return (
<main
@@ -92,7 +77,7 @@ function SettingsScreen() {
)
}
>
<span className="text-[#F9FBFE] text-sm">{t(text)}</span>
<span className="text-[#F9FBFE] text-sm">{text}</span>
</NavLink>
))}
</nav>
+1 -1
View File
@@ -45,7 +45,7 @@ function EmailInputSection({
isEmailChanged && !isEmailValid
? "border-red-500"
: "border-tertiary"
} flex-grow`}
} flex-grow focus:outline-hidden focus:border-transparent focus:ring-0`}
placeholder={t("SETTINGS$USER_EMAIL_LOADING")}
data-testid="email-input"
/>
-5
View File
@@ -11,7 +11,6 @@ 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) {
@@ -33,10 +32,6 @@ 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));
}
@@ -0,0 +1,81 @@
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);
});
});
});
-1
View File
@@ -15,7 +15,6 @@ export enum AgentState {
}
export const RUNTIME_INACTIVE_STATES = [
AgentState.INIT,
AgentState.LOADING,
AgentState.STOPPED,
AgentState.ERROR,
+1 -1
View File
@@ -11,7 +11,7 @@ export interface UserMessageAction extends OpenHandsActionEvent<"message"> {
}
export interface SystemMessageAction extends OpenHandsActionEvent<"system"> {
source: "agent" | "environment";
source: "agent";
args: {
content: string;
tools: Array<Record<string, unknown>> | null;
+1 -12
View File
@@ -5,15 +5,4 @@ export type RuntimeStatus =
| "STATUS$RUNTIME_STARTED"
| "STATUS$SETTING_UP_WORKSPACE"
| "STATUS$SETTING_UP_GIT_HOOKS"
| "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";
| "STATUS$READY";
@@ -1,104 +0,0 @@
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);
});
});
});
@@ -1,53 +0,0 @@
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