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
395 changed files with 3816 additions and 32698 deletions

1
.github/CODEOWNERS vendored
View File

@@ -3,7 +3,6 @@
# Frontend code owners
/frontend/ @rbren @amanape
/openhands-ui/ @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig

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

View File

@@ -40,8 +40,7 @@ jobs:
# Only build nikolaik on PRs, otherwise build both nikolaik and ubuntu.
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
json=$(jq -n -c '[
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" },
{ image: "ubuntu:24.04", tag: "ubuntu" }
{ image: "nikolaik/python-nodejs:python3.12-nodejs22", tag: "nikolaik" }
]')
else
json=$(jq -n -c '[
@@ -55,10 +54,12 @@ 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
outputs:
# Since this job uses outputs it cannot use matrix
hash_from_app_image: ${{ steps.get_hash_in_app_image.outputs.hash_from_app_image }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -84,12 +85,24 @@ jobs:
if: "!github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --push
- name: Build app image
if: "github.event.pull_request.head.repo.fork"
run: |
./containers/build.sh -i openhands -o ${{ env.REPO_OWNER }} --load
- name: Get hash in App Image
id: get_hash_in_app_image
run: |
# Run the build script in the app image
docker run -e SANDBOX_USER_ID=0 -v /var/run/docker.sock:/var/run/docker.sock ghcr.io/${{ env.REPO_OWNER }}/openhands:${{ env.RELEVANT_SHA }} /bin/bash -c "mkdir -p containers/runtime; python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild" 2>&1 | tee docker-outputs.txt
# Get the hash from the build script
hash_from_app_image=$(cat docker-outputs.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_app_image=$hash_from_app_image" >> $GITHUB_OUTPUT
echo "Hash from app image: $hash_from_app_image"
# Builds the runtime Docker images
ghcr_build_runtime:
name: Build Image
runs-on: blacksmith-8vcpu-ubuntu-2204
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
runs-on: blacksmith-4vcpu-ubuntu-2204
permissions:
contents: read
packages: write
@@ -115,13 +128,22 @@ jobs:
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
- name: Install poetry via pipx
run: pipx install poetry
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
# This is the one that saves the cache, the others set 'lookup-only: true'
restore-keys: |
${{ runner.os }}-poetry-
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Create source distribution and Dockerfile
@@ -166,6 +188,61 @@ jobs:
name: runtime-src-${{ matrix.base_image.tag }}
path: containers/runtime
verify_hash_equivalence_in_runtime_and_app:
name: Verify Hash Equivalence in Runtime and Docker images
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [ghcr_build_runtime, ghcr_build_app]
strategy:
fail-fast: false
matrix:
base_image: ['nikolaik']
env:
BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST: nikolaik/python-nodejs:python3.12-nodejs22
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main INSTALL_PLAYWRIGHT=0
- name: Get hash in App Image
run: |
echo "Hash from app image: ${{ needs.ghcr_build_app.outputs.hash_from_app_image }}"
echo "hash_from_app_image=${{ needs.ghcr_build_app.outputs.hash_from_app_image }}" >> $GITHUB_ENV
- name: Get hash using code (development mode)
run: |
mkdir -p containers/runtime
poetry run python3 openhands/runtime/utils/runtime_build.py --base_image ${{ env.BASE_IMAGE_FOR_HASH_EQUIVALENCE_TEST }} --build_folder containers/runtime --force_rebuild > output.txt 2>&1
hash_from_code=$(cat output.txt | grep "Hash for docker build directory" | awk -F "): " '{print $2}' | uniq | head -n1)
echo "hash_from_code=$hash_from_code" >> $GITHUB_ENV
- name: Compare hashes
run: |
echo "Hash from App Image: ${{ env.hash_from_app_image }}"
echo "Hash from Code: ${{ env.hash_from_code }}"
if [ "${{ env.hash_from_app_image }}" = "${{ env.hash_from_code }}" ]; then
echo "Hashes match!"
else
echo "Hashes do not match!"
exit 1
fi
# Run unit tests with the Docker runtime Docker images as root
test_runtime_root:
name: RT Unit Tests (Root)
@@ -197,17 +274,25 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies INSTALL_PLAYWRIGHT=0
- name: Run docker runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -259,17 +344,25 @@ jobs:
load: true
tags: ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
context: containers/runtime
- name: Install poetry via pipx
run: pipx install poetry
- name: Cache Poetry dependencies
uses: useblacksmith/cache@v5
with:
path: |
~/.cache/pypoetry
~/.virtualenvs
key: ${{ runner.os }}-poetry-${{ hashFiles('**/poetry.lock') }}
lookup-only: true
restore-keys: |
${{ runner.os }}-poetry-
- name: Set up Python
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
cache: poetry
- name: Install poetry via pipx
run: pipx install poetry
- name: Install Python dependencies using Poetry
run: make install-python-dependencies POETRY_GROUP=main,test,runtime INSTALL_PLAYWRIGHT=0
- name: Run runtime tests
shell: bash
run: |
# We install pytest-xdist in order to run tests across CPUs
poetry run pip install pytest-xdist
@@ -296,7 +389,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ !cancelled() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
steps:
- name: All tests passed
run: echo "All runtime tests have passed successfully!"
@@ -305,7 +398,7 @@ jobs:
name: All Runtime Tests Passed
if: ${{ cancelled() || contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: [test_runtime_root, test_runtime_oh]
needs: [test_runtime_root, test_runtime_oh, verify_hash_equivalence_in_runtime_and_app]
steps:
- name: Some tests failed
run: |
@@ -330,7 +423,6 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
SHORT_SHA: ${{ steps.short_sha.outputs.SHORT_SHA }}
shell: bash
run: |
echo "updating PR description"
DOCKER_RUN_COMMAND="docker run -it --rm \

View File

@@ -54,7 +54,7 @@ jobs:
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --with dev,test,runtime,evaluation
run: poetry install --with dev,test,runtime
- name: Configure config.toml for testing with Haiku
env:
@@ -179,8 +179,8 @@ jobs:
id: create_comment
uses: KeisukeYamashita/create-comment@v1
with:
# if triggered by PR, use PR number, otherwise use 9745 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 9745 }}
# if triggered by PR, use PR number, otherwise use 5318 as fallback issue number for manual triggers
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || 5318 }}
unique: false
comment: |
Trigger by: ${{ github.event_name == 'pull_request' && format('Pull Request (integration-test label on PR #{0})', github.event.pull_request.number) || (github.event_name == 'workflow_dispatch' && format('Manual Trigger: {0}', github.event.inputs.reason)) || 'Nightly Scheduled Run' }}

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

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

View File

@@ -1,108 +0,0 @@
name: Publish OpenHands UI Package
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
paths:
- "openhands-ui/**"
- ".github/workflows/npm-publish-ui.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: npm-publish-ui
cancel-in-progress: false
jobs:
check-version:
name: Check if version has changed
runs-on: blacksmith-4vcpu-ubuntu-2204
defaults:
run:
shell: bash
outputs:
should-publish: ${{ steps.version-check.outputs.should-publish }}
current-version: ${{ steps.version-check.outputs.current-version }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2 # Need previous commit to compare
- name: Check if version changed
id: version-check
run: |
# Get current version from package.json
CURRENT_VERSION=$(jq -r .version openhands-ui/package.json)
echo "current-version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
# Check if package.json version changed in this commit
if git diff HEAD~1 HEAD --name-only | grep -q "openhands-ui/package.json"; then
# Check if the version field specifically changed
if git diff HEAD~1 HEAD openhands-ui/package.json | grep -q '"version"'; then
echo "Version changed in package.json, will publish"
echo "should-publish=true" >> $GITHUB_OUTPUT
else
echo "package.json changed but version did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
else
echo "package.json did not change, skipping publish"
echo "should-publish=false" >> $GITHUB_OUTPUT
fi
publish:
name: Publish to npm
runs-on: blacksmith-4vcpu-ubuntu-2204
needs: check-version
if: needs.check-version.outputs.should-publish == 'true'
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build
- name: Check if package already exists on npm
id: npm-check
working-directory: ./openhands-ui
run: |
PACKAGE_NAME=$(jq -r .name package.json)
VERSION="${{ needs.check-version.outputs.current-version }}"
# Check if this version already exists on npm
if npm view "$PACKAGE_NAME@$VERSION" version 2>/dev/null; then
echo "Version $VERSION already exists on npm, skipping publish"
echo "already-exists=true" >> $GITHUB_OUTPUT
else
echo "Version $VERSION does not exist on npm, proceeding with publish"
echo "already-exists=false" >> $GITHUB_OUTPUT
fi
- name: Setup npm authentication
if: steps.npm-check.outputs.already-exists == 'false'
run: |
echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Publish to npm
if: steps.npm-check.outputs.already-exists == 'false'
working-directory: ./openhands-ui
run: |
# The prepublishOnly script will run automatically and build the package
npm publish
echo "✅ Successfully published @openhands/ui@${{ needs.check-version.outputs.current-version }} to npm"

View File

@@ -1,5 +1,5 @@
# Workflow that runs python tests
name: Run Python Tests
# Workflow that runs python unit tests
name: Run Python Unit Tests
# The jobs in this workflow are required, so they must run at all times
# * Always run on "main"
@@ -16,9 +16,9 @@ concurrency:
cancel-in-progress: true
jobs:
# Run python tests on Linux
# Run python unit tests on Linux
test-on-linux:
name: Python Tests on Linux
name: Python Unit Tests on Linux
runs-on: blacksmith-4vcpu-ubuntu-2204
env:
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
@@ -51,8 +51,6 @@ jobs:
run: poetry run pytest --forked -n auto -svv ./tests/unit
- name: Run Runtime Tests with CLIRuntime
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
- name: Run E2E Tests
run: poetry run pytest -svv tests/e2e
# Run specific Windows python tests
test-on-windows:

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

View File

@@ -1,34 +0,0 @@
name: Run UI Component Build
# * Always run on "main"
# * Run on PRs that have changes in the "openhands-ui" folder or this workflow
on:
push:
branches:
- main
pull_request:
paths:
- 'openhands-ui/**'
- '.github/workflows/ui-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:
ui-build:
name: Build openhands-ui
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version-file: "openhands-ui/.bun-version"
- name: Install dependencies
working-directory: ./openhands-ui
run: bun install --frozen-lockfile
- name: Build package
working-directory: ./openhands-ui
run: bun run build

View File

@@ -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
.gitignore vendored
View File

@@ -182,8 +182,6 @@ cython_debug/
.roo/rules
.cline/rules
.windsurf/rules
.repomix
repomix-output.txt
# evaluation
evaluation/evaluation_outputs

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`.
@@ -137,65 +118,3 @@ Your specialized knowledge and instructions here...
2. Add the setting to the backend:
- Add the setting to the `Settings` model in `openhands/storage/data_models/settings.py`
- Update any relevant backend code to apply the setting (e.g., in session creation)
### Adding New LLM Models
To add a new LLM model to OpenHands, you need to update multiple files across both frontend and backend:
#### Model Configuration Procedure:
1. **Frontend Model Arrays** (`frontend/src/utils/verified-models.ts`):
- Add the model to `VERIFIED_MODELS` array (main list of all verified models)
- Add to provider-specific arrays based on the model's provider:
- `VERIFIED_OPENAI_MODELS` for OpenAI models
- `VERIFIED_ANTHROPIC_MODELS` for Anthropic models
- `VERIFIED_MISTRAL_MODELS` for Mistral models
- `VERIFIED_OPENHANDS_MODELS` for models available through OpenHands provider
2. **Backend CLI Integration** (`openhands/cli/utils.py`):
- Add the model to the appropriate `VERIFIED_*_MODELS` arrays
- This ensures the model appears in CLI model selection
3. **Backend Model List** (`openhands/utils/llm.py`):
- **CRITICAL**: Add the model to the `openhands_models` list (lines 57-66) if using OpenHands provider
- This is required for the model to appear in the frontend model selector
- Format: `'openhands/model-name'` (e.g., `'openhands/o3'`)
4. **Backend LLM Configuration** (`openhands/llm/llm.py`):
- Add to feature-specific arrays based on model capabilities:
- `FUNCTION_CALLING_SUPPORTED_MODELS` if the model supports function calling
- `REASONING_EFFORT_SUPPORTED_MODELS` if the model supports reasoning effort parameters
- `CACHE_PROMPT_SUPPORTED_MODELS` if the model supports prompt caching
- `MODELS_WITHOUT_STOP_WORDS` if the model doesn't support stop words
5. **Validation**:
- Run backend linting: `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml`
- Run frontend linting: `cd frontend && npm run lint:fix`
- Run frontend build: `cd frontend && npm run build`
#### Model Verification Arrays:
- **VERIFIED_MODELS**: Main array of all verified models shown in the UI
- **VERIFIED_OPENAI_MODELS**: OpenAI models (LiteLLM doesn't return provider prefix)
- **VERIFIED_ANTHROPIC_MODELS**: Anthropic models (LiteLLM doesn't return provider prefix)
- **VERIFIED_MISTRAL_MODELS**: Mistral models (LiteLLM doesn't return provider prefix)
- **VERIFIED_OPENHANDS_MODELS**: Models available through OpenHands managed provider
#### Model Feature Support Arrays:
- **FUNCTION_CALLING_SUPPORTED_MODELS**: Models that support structured function calling
- **REASONING_EFFORT_SUPPORTED_MODELS**: Models that support reasoning effort parameters (like o1, o3)
- **CACHE_PROMPT_SUPPORTED_MODELS**: Models that support prompt caching for efficiency
- **MODELS_WITHOUT_STOP_WORDS**: Models that don't support stop word parameters
#### Frontend Model Integration:
- Models are automatically available in the model selector UI once added to verified arrays
- The `extractModelAndProvider` utility automatically detects provider from model arrays
- Provider-specific models are grouped and prioritized in the UI selection
#### CLI Model Integration:
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider

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

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.49-nikolaik`
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.47-nikolaik`
## Develop inside Docker container

View File

@@ -62,17 +62,17 @@ system requirements and more information.
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-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.49-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.49
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.

View File

@@ -51,17 +51,17 @@ OpenHands也可以使用Docker在本地系统上运行。
```bash
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.49-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.49-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
> **注意**: 如果您在0.44版本之前使用过OpenHands您可能需要运行 `mv ~/.openhands-state ~/.openhands` 来将对话历史迁移到新位置。

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.49-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.49-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.49
docker.all-hands.dev/all-hands-ai/openhands:0.47
```
**注**: バージョン0.44以前のOpenHandsを使用していた場合は、会話履歴を移行するために `mv ~/.openhands-state ~/.openhands` を実行してください。

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.')

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

View File

@@ -45,7 +45,6 @@ ENV OPENHANDS_BUILD_VERSION=$OPENHANDS_BUILD_VERSION
ENV SANDBOX_USER_ID=0
ENV FILE_STORE=local
ENV FILE_STORE_PATH=/.openhands
ENV INIT_GIT_IN_EMPTY_WORKSPACE=1
RUN mkdir -p $FILE_STORE_PATH
RUN mkdir -p $WORKSPACE_BASE

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.49-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:

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.49-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:

View File

@@ -34,7 +34,6 @@
{
"group": "Integrations",
"pages": [
"usage/cloud/bitbucket-installation",
"usage/cloud/github-installation",
"usage/cloud/gitlab-installation",
"usage/cloud/slack-installation"
@@ -67,9 +66,7 @@
"usage/llms/groq",
"usage/llms/local-llms",
"usage/llms/litellm-proxy",
"usage/llms/moonshot",
"usage/llms/openai-llms",
"usage/llms/openhands-llms",
"usage/llms/openrouter"
]
}

View File

@@ -1827,11 +1827,6 @@
"updated_at": {
"type": "string",
"format": "date-time"
},
"owner_type": {
"type": "string",
"enum": ["user", "organization"],
"nullable": true
}
}
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

View File

@@ -1,25 +0,0 @@
---
title: Bitbucket Integration
description: This guide walks you through the process of installing OpenHands Cloud for your Bitbucket repositories. Once
set up, it will allow OpenHands to work with your Bitbucket repository.
---
## Prerequisites
- Signed in to [OpenHands Cloud](https://app.all-hands.dev) with [a Bitbucket account](/usage/cloud/openhands-cloud).
## Adding Bitbucket Repository Access
Upon signing into OpenHands Cloud with a Bitbucket account, OpenHands will have access to your repositories.
## Working With Bitbucket Repos in Openhands Cloud
After signing in with a Bitbucket account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.

View File

@@ -9,9 +9,8 @@ description: The Cloud UI provides a web interface for interacting with OpenHand
The landing page is where you can:
- [Add GitHub repository access](/usage/cloud/github-installation#adding-github-repository-access) to OpenHands.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud),
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) or
[a Bitbucket repo](/usage/cloud/bitbucket-installation#working-with-bitbucket-repos-in-openhands-cloud) to start working on.
- [Select a GitHub repo](/usage/cloud/github-installation#working-with-github-repos-in-openhands-cloud) or
[a GitLab repo](/usage/cloud/gitlab-installation#working-with-gitlab-repos-in-openhands-cloud) to start working on.
- See `Suggested Tasks` for repositories that OpenHands has access to.
- Launch an empty conversation using `Launch from Scratch`.

View File

@@ -51,7 +51,8 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
On your repository, label an issue with `openhands` or add a message starting with
`@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a pull request if it determines that the issue has been successfully resolved.
@@ -64,8 +65,6 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
- Request updates
- Get code explanations
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -1,7 +1,7 @@
---
title: GitLab Integration
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
set up, it will allow OpenHands to work with your GitLab repository.
---
## Prerequisites
@@ -17,7 +17,7 @@ Upon signing into OpenHands Cloud with a GitLab account, OpenHands will have acc
After signing in with a Gitlab account, use the `select a repo` and `select a branch` dropdowns to select the
appropriate repository and branch you'd like OpenHands to work on. Then click on `Launch` to start the conversation!
![Connect Repo](/static/img/connect-repo-no-github.png)
![Connect Repo](/static/img/connect-repo.png)
## Using Tokens with Reduced Scopes
@@ -25,33 +25,6 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
## Working on GitLab Issues and Merge Requests Using Openhands
<Note>
This feature works for personal projects and is available for group projects with a
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
</Note>
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
### Working with Issues
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
1. Comment on the issue to let you know it is working on it.
- You can click on the link to track the progress on OpenHands Cloud.
2. Open a merge request if it determines that the issue has been successfully resolved.
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
### Working with Merge Requests
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
- Ask questions
- Request updates
- Get code explanations
## Next Steps
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).

View File

@@ -8,9 +8,9 @@ description: Getting started with OpenHands Cloud.
OpenHands Cloud is the hosted cloud version of All Hands AI's OpenHands. To get started with OpenHands Cloud,
visit [app.all-hands.dev](https://app.all-hands.dev).
You'll be prompted to connect with your GitHub, GitLab or Bitbucket account:
You'll be prompted to connect with your GitHub or GitLab account:
1. Click `Log in with GitHub`, `Log in with GitLab` or `Log in with Bitbucket`.
1. Click `Log in with GitHub` or `Log in with GitLab`.
2. Review the permissions requested by OpenHands and authorize the application.
- OpenHands will require certain permissions from your account. To read more about these permissions,
you can click the `Learn more` link on the authorization page.
@@ -22,6 +22,5 @@ Once you've connected your account, you can:
- [Install GitHub Integration](/usage/cloud/github-installation) to use OpenHands with your GitHub repositories.
- [Install GitLab Integration](/usage/cloud/gitlab-installation) to use OpenHands with your GitLab repositories.
- [Install Bitbucket Integration](/usage/cloud/bitbucket-installation) to use OpenHands with your Bitbucket repositories.
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
- [Use the Cloud API](/usage/cloud/cloud-api) to programmatically interact with OpenHands.

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.

View File

@@ -12,8 +12,7 @@ icon: question
[GitHub](/usage/cloud/github-installation), [GitLab](/usage/cloud/gitlab-installation),
and [Slack](/usage/cloud/slack-installation) integrations.
2. **Run on your own**: If you prefer to run it on your own hardware, follow our [Getting Started guide](/usage/local-setup).
3. **First steps**: Read over the [start building guidelines](/usage/getting-started) and
[prompting best practices](/usage/prompting/prompting-best-practices) to learn the basics.
3. **First steps**: Complete the [start building tutorial](/usage/getting-started) to learn the basics.
### Can I use OpenHands for production workloads?

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.49-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.49 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.cli.main --override-cli-mode true
```
@@ -123,8 +84,7 @@ docker run -it \
This launches the CLI in Docker, allowing you to interact with OpenHands.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
The `-e SANDBOX_USER_ID=$(id -u)` ensures files created by the agent in your workspace have the correct permissions.
The conversation history will be saved in `~/.openhands/sessions`.

View File

@@ -25,8 +25,7 @@ You can use the Settings page at any time to:
- Setup the LLM provider and model for OpenHands.
- [Setup the search engine](/usage/search-engine-setup).
- [Configure MCP servers](/usage/mcp).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup), [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup)
and [connect to Bitbucket](/usage/how-to/gui-mode#bitbucket-setup).
- [Connect to GitHub](/usage/how-to/gui-mode#github-setup) and [connect to GitLab](/usage/how-to/gui-mode#gitlab-setup).
- Set application settings like your preferred language, notifications and other preferences.
- [Manage custom secrets](/usage/common-settings#secrets-management).
@@ -123,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**:

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.49-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,14 +73,13 @@ 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.49 \
docker.all-hands.dev/all-hands-ai/openhands:0.47 \
python -m openhands.core.main -t "write a bash script that prints hi"
```
> **Note**: If you used OpenHands before version 0.44, run `mv ~/.openhands-state ~/.openhands` to migrate your conversation history.
The `-e SANDBOX_USER_ID=$(id -u)` is passed to the Docker command to ensure the sandbox user matches the host users
permissions. This prevents the agent from creating root-owned files in the mounted workspace.
## Additional Options
@@ -91,6 +90,6 @@ Common command-line options:
- `-b 10.0` - Set budget limit (USD)
- `--no-auto-continue` - Interactive mode
Run `poetry run python -m openhands.core.main --help` for all 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.

View File

@@ -10,8 +10,7 @@ This section is for users who want to connect OpenHands to different LLMs.
## Model Recommendations
Based on our evaluations of language models for coding tasks (using the SWE-bench dataset), we can provide some
recommendations for model selection. Our latest benchmarking results can be found in
[this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
recommendations for model selection. Our latest benchmarking results can be found in [this spreadsheet](https://docs.google.com/spreadsheets/d/1wOUdFCMyY6Nt0AIqF705KN4JKOWgeI4wUGUP60krXXs/edit?gid=0).
Based on these findings and community feedback, these are the latest models that have been verified to work reasonably well with OpenHands:
@@ -21,7 +20,6 @@ Based on these findings and community feedback, these are the latest models that
- [openai/o4-mini](https://openai.com/index/introducing-o3-and-o4-mini/)
- [gemini/gemini-2.5-pro](https://blog.google/technology/google-deepmind/gemini-model-thinking-updates-march-2025/)
- [deepseek/deepseek-chat](https://api-docs.deepseek.com/)
- [moonshot/kimi-k2-0711-preview](https://platform.moonshot.ai/docs/pricing/chat#generation-model-kimi-k2)
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
to help others using the same provider!
@@ -72,20 +70,17 @@ We have a few guides for running OpenHands with specific model providers:
- [Groq](/usage/llms/groq)
- [Local LLMs with SGLang or vLLM](/usage/llms/local-llms)
- [LiteLLM Proxy](/usage/llms/litellm-proxy)
- [Moonshot AI](/usage/llms/moonshot)
- [OpenAI](/usage/llms/openai-llms)
- [OpenHands](/usage/llms/openhands-llms)
- [OpenRouter](/usage/llms/openrouter)
## Model Customization
LLM providers have specific settings that can be customized to optimize their performance with OpenHands, such as:
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer.
- **Native Tool Calling**: Toggle native function/tool calling capabilities.
- **Custom Tokenizers**: For specialized models, you can add a suitable tokenizer
- **Native Tool Calling**: Toggle native function/tool calling capabilities
For detailed information about model customization, see
[LLM Configuration Options](/usage/configuration-options#llm-configuration).
For detailed information about model customization, see [LLM Configuration Options](configuration-options#llm-customization).
### API retries and rate limits

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.49-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.49-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.49
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.49
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

View File

@@ -1,25 +0,0 @@
---
title: Moonshot AI
description: How to use Moonshot AI models with OpenHands
---
## Using Moonshot AI with OpenHands
[Moonshot AI](https://platform.moonshot.ai/) offers several powerful models, including Kimi-K2, which has been verified to work well with OpenHands.
### Setup
1. Sign up for an account at [Moonshot AI Platform](https://platform.moonshot.ai/)
2. Generate an API key from your account settings
3. Configure OpenHands to use Moonshot AI:
| Setting | Value |
| --- | --- |
| LLM Provider | `moonshot` |
| LLM Model | `kimi-k2-0711-preview` |
| API Key | Your Moonshot API key |
### Recommended Models
- `moonshot/kimi-k2-0711-preview` - Kimi-K2 is Moonshot's most powerful model with a 131K context window, function calling support, and web search capabilities.

View File

@@ -1,34 +0,0 @@
---
title: OpenHands
description: OpenHands LLM provider with access to state-of-the-art (SOTA) agentic coding models.
---
## Obtain Your OpenHands LLM API Key
1. [Log in to OpenHands Cloud](/usage/cloud/openhands-cloud).
2. Go to the Settings page and navigate to the `API Keys` tab.
3. Copy your `LLM API Key`.
![OpenHands LLM API Key](/static/img/openhands-llm-api-key.png)
## Configuration
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
- `LLM Provider` to `OpenHands`
- `LLM Model` to the model you will be using (e.g. claude-sonnet-4-20250514)
- `API Key` to your OpenHands LLM API key copied from above
## Using OpenHands LLM Provider in the CLI
1. [Run OpenHands CLI](/usage/how-to/cli-mode).
2. To select OpenHands as the LLM provider:
- If this is your first time running the CLI, choose `openhands` and then select the model that you would like to use.
- If you have previously run the CLI, run the `/settings` command and select to modify the `Basic` settings. Then
choose `openhands` and finally the model.
![OpenHands Provider in CLI](/static/img/openhands-provider-cli.png)
## Pricing
Pricing follows official API provider rates.
[You can view model prices here.](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)

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.49-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.49-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.49
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.

View File

@@ -29,15 +29,6 @@ sse_servers = [
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# SHTTP Servers - External servers that communicate via Streamable HTTP
shttp_servers = [
# Basic SHTTP server with just a URL
"http://example.com:8080/mcp",
# SHTTP server with API key authentication
{url="https://secure-example.com/mcp", api_key="your-api-key"}
]
# Stdio Servers - Local processes that communicate via standard input/output
stdio_servers = [
# Basic stdio server
@@ -66,22 +57,6 @@ SSE servers are configured using either a string URL or an object with the follo
- Type: `str`
- Description: The URL of the SSE server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### SHTTP Servers
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
- `url` (required)
- Type: `str`
- Description: The URL of the SHTTP server
- `api_key` (optional)
- Type: `str`
- Description: API key for authentication
### Stdio Servers
Stdio servers are configured using an object with the following properties:
@@ -109,7 +84,7 @@ Stdio servers are configured using an object with the following properties:
When OpenHands starts, it:
1. Reads the MCP configuration.
2. Connects to any configured SSE and SHTTP servers.
2. Connects to any configured SSE servers.
3. Starts any configured stdio servers.
4. Registers the tools provided by these servers with the agent.
@@ -118,23 +93,3 @@ The agent can then use these tools just like any built-in tool. When the agent c
1. OpenHands routes the call to the appropriate MCP server.
2. The server processes the request and returns a response.
3. OpenHands converts the response to an observation and presents it to the agent.
## Transport Protocols
OpenHands supports three different MCP transport protocols:
### Server-Sent Events (SSE)
SSE is a legacy HTTP-based transport that uses Server-Sent Events for server-to-client communication and HTTP POST requests for client-to-server communication. This transport is suitable for basic streaming scenarios but has limitations in session management and connection resumability.
### Streamable HTTP (SHTTP)
SHTTP is the modern HTTP-based transport protocol that provides enhanced features over SSE:
- **Improved Session Management**: Supports stateful sessions with session IDs for maintaining context across requests
- **Connection Resumability**: Can resume broken connections and replay missed messages using event IDs
- **Bidirectional Communication**: Uses HTTP POST for client-to-server and optional SSE streams for server-to-client communication
- **Better Error Handling**: Enhanced error reporting and recovery mechanisms
SHTTP is the recommended transport for HTTP-based MCP servers as it provides better reliability and features compared to the legacy SSE transport.
### Standard Input/Output (stdio)
Stdio transport enables communication through standard input and output streams, making it ideal for local integrations and command-line tools. This transport is used for locally executed MCP servers that run as separate processes.

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.

View File

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

View File

@@ -101,14 +101,13 @@ The OpenHands evaluation harness supports a wide variety of benchmarks across [s
- SWE-Bench: [`evaluation/benchmarks/swe_bench`](./benchmarks/swe_bench)
- HumanEvalFix: [`evaluation/benchmarks/humanevalfix`](./benchmarks/humanevalfix)
- BIRD: [`evaluation/benchmarks/bird`](./benchmarks/bird)
- BioCoder: [`evaluation/benchmarks/biocoder`](./benchmarks/biocoder)
- BioCoder: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- ML-Bench: [`evaluation/benchmarks/ml_bench`](./benchmarks/ml_bench)
- APIBench: [`evaluation/benchmarks/gorilla`](./benchmarks/gorilla/)
- ToolQA: [`evaluation/benchmarks/toolqa`](./benchmarks/toolqa/)
- AiderBench: [`evaluation/benchmarks/aider_bench`](./benchmarks/aider_bench/)
- Commit0: [`evaluation/benchmarks/commit0_bench`](./benchmarks/commit0_bench/)
- DiscoveryBench: [`evaluation/benchmarks/discoverybench`](./benchmarks/discoverybench/)
- TerminalBench: [`evaluation/benchmarks/terminal_bench`](./benchmarks/terminal_bench)
### Web Browsing

View File

@@ -41,10 +41,6 @@ default, it is set to 1.
- `language`, the language of your evaluating dataset.
- `dataset`, the absolute position of the dataset jsonl.
**Skipping errors on build**
For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
The results will be generated in evaluation/evaluation_outputs/outputs/XXX/CodeActAgent/YYY/output.jsonl, you can refer to the [example](examples/output.jsonl).
## Runing evaluation

View File

@@ -17,7 +17,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -844,5 +843,3 @@ if __name__ == '__main__':
timeout_seconds=120 * 60, # 2 hour PER instance should be more than enough
max_retries=5,
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)

View File

@@ -38,10 +38,6 @@ Please follow instruction [here](../../README.md#setup) to setup your local deve
> - If your LLM config has temperature=0, we will automatically use temperature=0.1 for the 2nd and 3rd attempts
>
> To enable this iterative protocol, set `export ITERATIVE_EVAL_MODE=true`
>
> **Skipping errors on build**
>
> For debugging purposes, you can set `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=true` to continue evaluation even when instances reach maximum retries. After evaluation completes, check `maximum_retries_exceeded.jsonl` for a list of failed instances, fix those issues, and then run the evaluation again with `export EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false`.
### Running Locally with Docker

View File

@@ -1,45 +0,0 @@
# **Localization Evaluation for SWE-Bench**
This folder implements localization evaluation at both file and function levels to complementing the assessment of agent inference on [SWE-Bench](https://www.swebench.com/).
## **1. Environment Setup**
- Python env: [Install python environment](../../../README.md#development-environment)
- LLM config: [Configure LLM config](../../../README.md#configure-openhands-and-your-llm)
## **2. Inference & Evaluation**
- Inference and evaluation follow the original `run_infer.sh` and `run_eval.sh` implementation
- You may refer to instructions at [README.md](../README.md) for running inference and evaluation on SWE-Bench
## **3. Localization Evaluation**
- Localization evaluation computes two-level localization accuracy, while also considers task success as an additional metric for overall evaluation:
- **File Localization Accuracy:** Accuracy of correctly localizing the target file
- **Function Localization Accuracy:** Accuracy of correctly localizing the target function
- **Resolve Rate** (will be auto-skipped if missing): Success rate of whether tasks are successfully resolved
- **File Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Function Localization Efficiency:** Average number of iterations taken to successfully localize the target file
- **Task success efficiency:** Average number of iterations taken to resolve the task
- **Resource efficiency:** the API expenditure of the agent running inference on SWE-Bench instances
- Run localization evaluation
- Format:
```bash
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh [infer-dir] [split] [dataset] [max-infer-turn] [align-with-max]
```
- `infer-dir`: inference directory containing inference outputs
- `split`: SWE-Bench dataset split to use
- `dataset`: SWE-Bench dataset name
- `max-infer-turn`: the maximum number of iterations the agent took to run inference
- `align-with-max`: whether to align failure indices (e.g., incorrect localization, unresolved tasks) with `max_iter`
- Example:
```bash
# Example
./evaluation/benchmarks/swe_bench/scripts/eval_localization.sh \
--infer-dir ./evaluation/evaluation_outputs/outputs/princeton-nlp__SWE-bench_Verified-test/CodeActAgent/gpt_4o_100_N \
--split test \
--dataset princeton-nlp/SWE-bench_Verified \
--max-infer-turn 100 \
--align-with-max true
```
- Localization evaluation results will be automatically saved to `[infer-dir]/loc_eval`

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,6 @@ from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
assert_and_raise,
check_maximum_retries_exceeded,
codeact_user_response,
get_default_sandbox_config_for_eval,
get_metrics,
@@ -110,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:
@@ -969,5 +970,3 @@ if __name__ == '__main__':
logger.info(
f'Done! Total {len(added_instance_ids)} instances added to {output_file}'
)
# Check if any instances reached maximum retries
check_maximum_retries_exceeded(metadata.eval_output_dir)

View File

@@ -1,227 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Function to display usage information
usage() {
echo "Usage: $0 [OPTIONS]"
echo "Options:"
echo " --infer-dir DIR Directory containing model inference outputs"
echo " --split SPLIT SWE-Bench dataset split selection"
echo " --dataset DATASET Dataset name"
echo " --max-infer-turn NUM Max number of turns for coding agent"
echo " --align-with-max BOOL Align failed instance indices with max iteration (true/false)"
echo " -h, --help Display this help message"
echo ""
echo "Example:"
echo " $0 --infer-dir ./inference_outputs --split test --align-with-max false"
}
# Check if no arguments were provided
if [ $# -eq 0 ]; then
usage
exit 1
fi
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--infer-dir)
INFER_DIR="$2"
shift 2
;;
--split)
SPLIT="$2"
shift 2
;;
--dataset)
DATASET="$2"
shift 2
;;
--max-infer-turn)
MAX_TURN="$2"
shift 2
;;
--align-with-max)
ALIGN_WITH_MAX="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown option: $1"
usage
exit 1
;;
esac
done
# Check for required arguments (only INFER_DIR is required)
if [ -z "$INFER_DIR" ]; then
echo "Error: Missing required arguments (--infer-dir is required)"
usage
exit 1
fi
# Set defaults for optional arguments if not provided
if [ -z "$SPLIT" ]; then
SPLIT="test"
echo "Split not specified, using default: $SPLIT"
fi
if [ -z "$DATASET" ]; then
DATASET="princeton-nlp/SWE-bench_Verified"
echo "Dataset not specified, using default: $DATASET"
fi
if [ -z "$MAX_TURN" ]; then
MAX_TURN=20
echo "Max inference turn not specified, using default: $MAX_TURN"
fi
if [ -z "$ALIGN_WITH_MAX" ]; then
ALIGN_WITH_MAX="true"
echo "Align with max not specified, using default: $ALIGN_WITH_MAX"
fi
# Validate align-with-max value
if [ "$ALIGN_WITH_MAX" != "true" ] && [ "$ALIGN_WITH_MAX" != "false" ]; then
print_error "Invalid value for --align-with-max: $ALIGN_WITH_MAX. Must be 'true' or 'false'"
exit 1
fi
# Color codes for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${GREEN}[INFO]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_header() {
echo -e "${BLUE}[TASK]${NC} $1"
}
# Check if Python is available
print_header "Checking Python installation..."
if ! command -v python3 &> /dev/null; then
if ! command -v python &> /dev/null; then
print_error "Python is not installed or not in PATH"
exit 1
else
PYTHON_CMD="python"
print_status "Using python command"
fi
else
PYTHON_CMD="python3"
print_status "Using python3 command"
fi
# Check if the Python script exists
SCRIPT_NAME="./evaluation/benchmarks/swe_bench/loc_eval/loc_evaluator.py"
if [ ! -f "$SCRIPT_NAME" ]; then
print_error "Python script '$SCRIPT_NAME' not found in current directory"
print_warning "Make sure the Python script is in the same directory as this bash script"
exit 1
fi
# Check if required directories exist
print_header "Validating directories..."
if [ ! -d "$INFER_DIR" ]; then
print_error "Inference directory not found: $INFER_DIR"
exit 1
fi
# Evaluation outputs
EVAL_DIR="$INFER_DIR/eval_outputs"
# Display configuration
print_header "Starting Localization Evaluation with the following configuration:"
echo " Inference Directory: $INFER_DIR"
if [ -d "$EVAL_DIR" ]; then
echo " Evaluation Directory: $EVAL_DIR"
else
echo " Evaluation Directory: None (evaluation outputs doesn't exist)"
fi
echo " Output Directory: $INFER_DIR/loc_eval"
echo " Split: $SPLIT"
echo " Dataset: $DATASET"
echo " Max Turns: $MAX_TURN"
echo " Align with Max: $ALIGN_WITH_MAX"
echo " Python Command: $PYTHON_CMD"
echo ""
# Check Python dependencies (optional check)
print_header "Checking Python dependencies..."
$PYTHON_CMD -c "
import sys
required_modules = ['pandas', 'json', 'os', 'argparse', 'collections']
missing_modules = []
for module in required_modules:
try:
__import__(module)
except ImportError:
missing_modules.append(module)
if missing_modules:
print(f'Missing required modules: {missing_modules}')
sys.exit(1)
else:
print('All basic dependencies are available')
" || {
print_error "Some Python dependencies are missing"
print_warning "Please install required packages: pip install pandas"
exit 1
}
# Create log directory if doesn't exists
mkdir -p "$INFER_DIR/loc_eval"
# Set up logging
LOG_FILE="$INFER_DIR/loc_eval/loc_evaluation_$(date +%Y%m%d_%H%M%S).log"
print_status "Logging output to: $LOG_FILE"
# Build the command
CMD_ARGS="\"$SCRIPT_NAME\" \
--infer-dir \"$INFER_DIR\" \
--split \"$SPLIT\" \
--dataset \"$DATASET\" \
--max-infer-turn \"$MAX_TURN\" \
--align-with-max \"$ALIGN_WITH_MAX\""
# Run the Python script
print_header "Running localization evaluation..."
eval "$PYTHON_CMD $CMD_ARGS" 2>&1 | tee "$LOG_FILE"
# Check if the script ran successfully
if [ ${PIPESTATUS[0]} -eq 0 ]; then
print_status "Localization evaluation completed successfully!"
print_status "Results saved to: $INFER_DIR/loc_eval"
print_status "Log file: $LOG_FILE"
# Display summary if results exist
if [ -f "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json" ]; then
print_header "Evaluation Summary:"
cat "$INFER_DIR/loc_eval/loc_eval_results/loc_acc/overall_eval.json"
echo
fi
else
print_error "Localization evaluation failed!"
print_warning "Check the log file for details: $LOG_FILE"
exit 1
fi

View File

@@ -1,31 +0,0 @@
# Terminal-Bench Evaluation on OpenHands
Terminal-Bench has its own evaluation harness that is very different from OpenHands'. We
implemented [OpenHands agent](https://github.com/laude-institute/terminal-bench/tree/main/terminal_bench/agents/installed_agents/openhands) using OpenHands local runtime
inside terminal-bench framework. Hereby we introduce how to use the terminal-bench
harness to evaluate OpenHands.
## Installation
Terminal-bench ships a CLI tool to manage tasks and run evaluation.
Please follow official [Installation Doc](https://www.tbench.ai/docs/installation). You could also clone terminal-bench [source code](https://github.com/laude-institute/terminal-bench) and use `uv run tb` CLI.
## Evaluation
Please see [Terminal-Bench Leaderboard](https://www.tbench.ai/leaderboard) for the latest
instruction on benchmarking guidance. The dataset might evolve.
Sample command:
```bash
export LLM_BASE_URL=<optional base url>
export LLM_API_KEY=<llm key>
tb run \
--dataset-name terminal-bench-core \
--dataset-version 0.1.1 \
--agent openhands \
--model <model> \
--cleanup
```
You could run `tb --help` or `tb run --help` to learn more about their CLI.

View File

@@ -25,8 +25,7 @@ class Test(BaseIntegrationTest):
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
# git add
cmd_str = 'git add hello.py'
action = CmdRunAction(command=cmd_str)
action = CmdRunAction(command='git add hello.py .vscode/')
obs = runtime.run_action(action)
assert_and_raise(obs.exit_code == 0, f'Failed to run command: {obs.content}')
@@ -41,6 +40,15 @@ class Test(BaseIntegrationTest):
reason=f'Failed to cat /workspace/hello.py: {obs.content}.',
)
# check if the file /workspace/.vscode/settings.json exists
action = CmdRunAction(command='cat /workspace/.vscode/settings.json')
obs = runtime.run_action(action)
if obs.exit_code != 0:
return TestResult(
success=False,
reason=f'Failed to cat /workspace/.vscode/settings.json: {obs.content}.',
)
# check if the staging area is empty
action = CmdRunAction(command='git status')
obs = runtime.run_action(action)

View File

@@ -311,76 +311,6 @@ def assert_and_raise(condition: bool, msg: str):
raise EvalException(msg)
def log_skipped_maximum_retries_exceeded(instance, metadata, error, max_retries=5):
"""Log and skip the instance when maximum retries are exceeded.
Args:
instance: The instance that failed
metadata: The evaluation metadata
error: The error that occurred
max_retries: The maximum number of retries that were attempted
Returns:
EvalOutput with the error information
"""
from openhands.core.logger import openhands_logger as logger
# Log the error
logger.exception(error)
logger.error(
f'Maximum error retries reached for instance {instance.instance_id}. '
f'Check maximum_retries_exceeded.jsonl, fix the issue and run evaluation again. '
f'Skipping this instance and continuing with others.'
)
# Add the instance name to maximum_retries_exceeded.jsonl in the same folder as output.jsonl
if metadata and metadata.eval_output_dir:
retries_file_path = os.path.join(
metadata.eval_output_dir,
'maximum_retries_exceeded.jsonl',
)
try:
# Write the instance info as a JSON line
with open(retries_file_path, 'a') as f:
import json
# No need to get Docker image as we're not including it in the error entry
error_entry = {
'instance_id': instance.instance_id,
'error': str(error),
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
}
f.write(json.dumps(error_entry) + '\n')
logger.info(f'Added instance {instance.instance_id} to {retries_file_path}')
except Exception as write_error:
logger.error(
f'Failed to write to maximum_retries_exceeded.jsonl: {write_error}'
)
return EvalOutput(
instance_id=instance.instance_id,
test_result={},
error=f'Maximum retries ({max_retries}) reached: {str(error)}',
status='error',
)
def check_maximum_retries_exceeded(eval_output_dir):
"""Check if maximum_retries_exceeded.jsonl exists and output a message."""
from openhands.core.logger import openhands_logger as logger
retries_file_path = os.path.join(eval_output_dir, 'maximum_retries_exceeded.jsonl')
if os.path.exists(retries_file_path):
logger.info(
'ATTENTION: Some instances reached maximum error retries and were skipped.'
)
logger.info(f'These instances are listed in: {retries_file_path}')
logger.info(
'Fix these instances and run evaluation again with EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED=false'
)
def _process_instance_wrapper(
process_instance_func: Callable[[pd.Series, EvalMetadata, bool], EvalOutput],
instance: pd.Series,
@@ -433,26 +363,11 @@ def _process_instance_wrapper(
+ f'[Encountered after {max_retries} retries. Please check the logs and report the issue.]'
+ '-' * 10
)
# Check if EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED is set to true
skip_errors = (
os.environ.get(
'EVAL_SKIP_MAXIMUM_RETRIES_EXCEEDED', 'false'
).lower()
== 'true'
)
if skip_errors:
# Use the dedicated function to log and skip maximum retries exceeded
return log_skipped_maximum_retries_exceeded(
instance, metadata, e, max_retries
)
else:
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
# Raise an error after all retries & stop the evaluation
logger.exception(e)
raise RuntimeError(
f'Maximum error retries reached for instance {instance.instance_id}'
) from e
msg = (
'-' * 10
+ '\n'
@@ -541,10 +456,6 @@ def run_evaluation(
output_fp.close()
logger.info('\nEvaluation finished.\n')
# Check if any instances reached maximum retries
if metadata and metadata.eval_output_dir:
check_maximum_retries_exceeded(metadata.eval_output_dir)
def reset_logger_for_multiprocessing(
logger: logging.Logger, instance_id: str, log_dir: str

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

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

View File

@@ -129,159 +129,4 @@ describe("ActionSuggestions", () => {
expect(createPRPrompt).toContain("meaningful branch name");
expect(createPRPrompt).not.toContain("SAME branch name");
});
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
// Test case for GitHub repository
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-github",
title: "GitHub Test",
selected_repository: "test-repo",
git_provider: "github",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
// Mock user having both GitHub and Bitbucket tokens
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "github-token",
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
expect(prButton).toBeInTheDocument();
if (prButton) {
prButton.click();
}
// The suggestion should mention GitHub, not Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitHub")
);
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
it("should use GitLab terminology when git_provider is gitlab", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-gitlab",
title: "GitLab Test",
selected_repository: "test-repo",
git_provider: "gitlab",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
gitlab: "gitlab-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention GitLab and "merge request" instead of "pull request"
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("GitLab")
);
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("merge request")
);
});
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
getConversationSpy.mockResolvedValue({
conversation_id: "test-bitbucket",
title: "Bitbucket Test",
selected_repository: "test-repo",
git_provider: "bitbucket",
selected_branch: "main",
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null,
});
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
bitbucket: "bitbucket-token",
},
});
const onSuggestionsClick = vi.fn();
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
const buttons = await screen.findAllByTestId("suggestion");
const prButton = buttons.find((button) =>
button.textContent?.includes("Push & Create PR"),
);
if (prButton) {
prButton.click();
}
// Should mention Bitbucket
expect(onSuggestionsClick).toHaveBeenCalledWith(
expect.stringContaining("Bitbucket")
);
});
});

View File

@@ -44,64 +44,4 @@ describe("AuthModal", () => {
expect(window.location.href).toBe(mockUrl);
});
it("should render Terms of Service and Privacy Policy text with correct links", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
// Find the terms of service section using data-testid
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
);
expect(termsSection).toHaveTextContent("COMMON$TERMS_OF_SERVICE");
expect(termsSection).toHaveTextContent("COMMON$AND");
expect(termsSection).toHaveTextContent("COMMON$PRIVACY_POLICY");
// Check Terms of Service link
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toBeInTheDocument();
expect(tosLink).toHaveAttribute("href", "https://www.all-hands.dev/tos");
expect(tosLink).toHaveAttribute("target", "_blank");
expect(tosLink).toHaveClass("underline", "hover:text-primary");
// Check Privacy Policy link
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toBeInTheDocument();
expect(privacyLink).toHaveAttribute(
"href",
"https://www.all-hands.dev/privacy",
);
expect(privacyLink).toHaveAttribute("target", "_blank");
expect(privacyLink).toHaveClass("underline", "hover:text-primary");
// Verify that both links are within the terms section
expect(termsSection).toContainElement(tosLink);
expect(termsSection).toContainElement(privacyLink);
});
it("should open Terms of Service link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const tosLink = screen.getByRole("link", {
name: "COMMON$TERMS_OF_SERVICE",
});
expect(tosLink).toHaveAttribute("target", "_blank");
});
it("should open Privacy Policy link in new tab", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const privacyLink = screen.getByRole("link", {
name: "COMMON$PRIVACY_POLICY",
});
expect(privacyLink).toHaveAttribute("target", "_blank");
});
});

View File

@@ -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();

View File

@@ -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"
/>,
);

View File

@@ -295,521 +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();
});
it("should show edit button in context menu", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3);
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Edit button should be visible
const editButton = screen.getByTestId("edit-button");
expect(editButton).toBeInTheDocument();
expect(editButton).toHaveTextContent("BUTTON$EDIT_TITLE");
});
it("should enter edit mode when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Should find input field instead of title text
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
expect(titleInput).toBeInTheDocument();
expect(titleInput.tagName).toBe("INPUT");
expect(titleInput).toHaveValue("Conversation 1");
expect(titleInput).toHaveFocus();
});
it("should successfully update conversation title", async () => {
const user = userEvent.setup();
// Mock the updateConversation API call
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
// Mock the toast function
const mockToast = vi.fn();
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: mockToast,
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Updated Title");
// Blur the input to save
await user.tab();
// Verify API call was made with correct parameters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Updated Title",
});
});
it("should save title when Enter key is pressed", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title and press Enter
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Title Updated via Enter");
await user.keyboard("{Enter}");
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Title Updated via Enter",
});
});
it("should trim whitespace from title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with extra whitespace
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, " Trimmed Title ");
await user.tab();
// Verify API call was made with trimmed title
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Trimmed Title",
});
// Verify input shows trimmed value
expect(titleInput).toHaveValue("Trimmed Title");
});
it("should revert to original title when empty", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Clear the title completely
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.tab();
// Verify API was not called
expect(updateConversationSpy).not.toHaveBeenCalled();
// Verify input reverted to original value
expect(titleInput).toHaveValue("Conversation 1");
});
it("should handle API error when updating title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockRejectedValue(new Error("API Error"));
vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Failed Update");
await user.tab();
// Verify API call was made
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Failed Update",
});
// Wait for error handling
await waitFor(() => {
expect(updateConversationSpy).toHaveBeenCalled();
});
});
it("should close context menu when edit button is clicked", async () => {
const user = userEvent.setup();
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Click ellipsis to open context menu
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Verify context menu is open
const contextMenu = screen.getByTestId("context-menu");
expect(contextMenu).toBeInTheDocument();
// Click edit button
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Verify context menu is closed
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
});
it("should not call API when title is unchanged", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Don't change the title, just blur
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.tab();
// Verify API was called with the same title (since handleConversationTitleChange will always be called)
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Conversation 1",
});
});
it("should handle special characters in title", async () => {
const user = userEvent.setup();
const updateConversationSpy = vi.spyOn(OpenHands, "updateConversation");
updateConversationSpy.mockResolvedValue(true);
renderConversationPanel();
const cards = await screen.findAllByTestId("conversation-card");
// Enter edit mode
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const editButton = screen.getByTestId("edit-button");
await user.click(editButton);
// Edit the title with special characters
const titleInput = within(cards[0]).getByTestId("conversation-card-title");
await user.clear(titleInput);
await user.type(titleInput, "Special @#$%^&*()_+ Characters");
await user.tab();
// Verify API call was made with special characters
expect(updateConversationSpy).toHaveBeenCalledWith("1", {
title: "Special @#$%^&*()_+ Characters",
});
});
});

View File

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

View File

@@ -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,
);
});
});

View File

@@ -110,29 +110,4 @@ describe("TaskCard", () => {
expect(launchButton).toHaveTextContent(/Loading/i);
expect(launchButton).toBeDisabled();
});
it("should navigate to the conversation page after creating a conversation", async () => {
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
createConversationSpy.mockResolvedValue({
conversation_id: "test-conversation-id",
title: "Test Conversation",
selected_repository: "repo1",
selected_branch: "main",
git_provider: "github",
last_updated_at: "2023-01-01T00:00:00Z",
created_at: "2023-01-01T00:00:00Z",
status: "RUNNING",
runtime_status: "STATUS$READY",
url: null,
session_api_key: null
});
renderTaskCard();
const launchButton = screen.getByTestId("task-launch-button");
await userEvent.click(launchButton);
// Wait for navigation to the conversation page
await screen.findByTestId("conversation-screen");
});
});

View File

@@ -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);
});
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

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");
});

View File

@@ -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();
});
});

View File

@@ -82,5 +82,17 @@ describe("extractModelAndProvider", () => {
model: "claude-opus-4-20250514",
separator: "/",
});
expect(extractModelAndProvider("claude-3-haiku-20240307")).toEqual({
provider: "anthropic",
model: "claude-3-haiku-20240307",
separator: "/",
});
expect(extractModelAndProvider("claude-2.1")).toEqual({
provider: "anthropic",
model: "claude-2.1",
separator: "/",
});
});
});

View File

@@ -52,16 +52,14 @@ test("organizeModelsAndProviders", () => {
separator: "/",
models: [
"claude-3-5-sonnet-20241022",
],
},
other: {
separator: "",
models: [
"together-ai-21.1b-41b",
"claude-3-haiku-20240307",
"claude-2",
"claude-2.1",
],
},
other: {
separator: "",
models: ["together-ai-21.1b-41b"],
},
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,55 @@
{
"name": "openhands-frontend",
"version": "0.49.0",
"version": "0.47.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.0.0"
"node": ">=20.0.0"
},
"dependencies": {
"@heroui/react": "^2.8.1",
"@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.7.0",
"@react-router/serve": "^7.7.0",
"@react-router/node": "^7.6.3",
"@react-router/serve": "^7.6.3",
"@react-types/shared": "^3.29.1",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.7.0",
"@stripe/stripe-js": "^7.5.0",
"@stripe/stripe-js": "^7.4.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@vitejs/plugin-react": "^4.7.0",
"@tanstack/react-query": "^5.81.4",
"@vitejs/plugin-react": "^4.6.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.10.0",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.23.6",
"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.12",
"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",
"react-router": "^7.7.0",
"react-router": "^7.6.3",
"react-syntax-highlighter": "^15.6.1",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"sirv-cli": "^3.0.1",
"socket.io-client": "^4.8.1",
"tailwind-merge": "^3.3.1",
"vite": "^7.0.5",
"vite": "^7.0.0",
"web-vitals": "^5.0.3",
"ws": "^8.18.2"
},
@@ -71,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": {
@@ -80,19 +80,19 @@
]
},
"devDependencies": {
"@babel/parser": "^7.28.0",
"@babel/traverse": "^7.28.0",
"@babel/types": "^7.28.1",
"@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.54.1",
"@react-router/dev": "^7.7.0",
"@playwright/test": "^1.53.1",
"@react-router/dev": "^7.6.3",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.14",
"@types/node": "^24.0.5",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -107,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",
@@ -119,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",

View File

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

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);
}

View File

@@ -477,36 +477,6 @@ class OpenHands {
return data.prompt;
}
static async updateConversation(
conversationId: string,
updates: { title: string },
): Promise<boolean> {
const { data } = await openHands.patch<boolean>(
`/api/conversations/${conversationId}`,
updates,
);
return data;
}
/**
* Get the GitHub user installation IDs
* @returns List of GitHub installation IDs
*/
static async getGitHubUserInstallationIds(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/github/installations");
return data;
}
/**
* Get the BitBucket workspaces
* @returns List of BitBucket workspaces
*/
static async getBitBucketWorkspaces(): Promise<string[]> {
const { data } = await openHands.get<string[]>("/bitbucket/installations");
return data;
}
}
export default OpenHands;

View File

@@ -50,11 +50,9 @@ export interface GetConfigResponse {
GITHUB_CLIENT_ID: string;
POSTHOG_CLIENT_KEY: string;
STRIPE_PUBLISHABLE_KEY?: string;
PROVIDERS_CONFIGURED?: Provider[];
FEATURE_FLAGS: {
ENABLE_BILLING: boolean;
HIDE_LLM_SETTINGS: boolean;
HIDE_MICROAGENT_MANAGEMENT?: boolean;
};
}
@@ -73,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 {

View File

@@ -19,11 +19,8 @@ export function ActionSuggestions({
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
// Use the git_provider from the conversation, not the user's authenticated providers
const currentGitProvider = conversation?.git_provider;
const isGitLab = currentGitProvider === "gitlab";
const isBitbucket = currentGitProvider === "bitbucket";
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
const pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";

View File

@@ -29,7 +29,7 @@ export function ChatInput({
disabled,
showButton = true,
value,
maxRows = 8,
maxRows = 16,
onSubmit,
onStop,
onChange,

View File

@@ -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();
});
});

View File

@@ -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, [], [])}

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { cn } from "#/utils/utils";
import { ul, ol } from "../markdown/list";
@@ -86,19 +85,21 @@ export function ChatMessage({
/>
</div>
<div className="text-sm break-words">
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
>
{message}
</Markdown>
<div className="text-sm break-words flex">
<div>
<Markdown
components={{
code,
ul,
ol,
a: anchor,
p: paragraph,
}}
remarkPlugins={[remarkGfm]}
>
{message}
</Markdown>
</div>
</div>
{children}
</article>

View File

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

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useTranslation } from "react-i18next";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
@@ -47,7 +46,7 @@ export function ErrorMessage({ errorId, defaultMessage }: ErrorMessageProps) {
ul,
ol,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{defaultMessage}
</Markdown>

View File

@@ -164,7 +164,7 @@ export function EventMessage({
const message = parseMessageFromEvent(event);
return (
<>
<div className="flex flex-col self-end">
<ChatMessage type={event.source} message={message} actions={actions}>
{event.args.image_urls && event.args.image_urls.length > 0 && (
<ImageCarousel size="small" images={event.args.image_urls} />
@@ -184,7 +184,7 @@ export function EventMessage({
{isAssistantMessage(event) &&
event.action === "message" &&
renderLikertScale()}
</>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { Trans, useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { Link } from "react-router";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { useConfig } from "#/hooks/query/use-config";
import { I18nKey } from "#/i18n/declaration";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -200,7 +199,7 @@ export function ExpandableMessage({
ol,
p: paragraph,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>

View File

@@ -1,7 +1,6 @@
import React from "react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkBreaks from "remark-breaks";
import { code } from "../markdown/code";
import { ol, ul } from "../markdown/list";
import ArrowDown from "#/icons/angle-down-solid.svg?react";
@@ -53,7 +52,7 @@ export function GenericEventMessage({
ul,
ol,
}}
remarkPlugins={[remarkGfm, remarkBreaks]}
remarkPlugins={[remarkGfm]}
>
{details}
</Markdown>

View File

@@ -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) => {

View File

@@ -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}
/>
))}

View File

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

View File

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

View File

@@ -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>
);
}

View File

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

View File

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

View File

@@ -5,15 +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";
import { useUpdateConversation } from "#/hooks/mutation/use-update-conversation";
import { displaySuccessToast } from "#/utils/custom-toast-handlers";
interface ConversationPanelProps {
onClose: () => void;
@@ -27,8 +22,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [confirmDeleteModalVisible, setConfirmDeleteModalVisible] =
React.useState(false);
const [confirmStopModalVisible, setConfirmStopModalVisible] =
React.useState(false);
const [
confirmExitConversationModalVisible,
setConfirmExitConversationModalVisible,
@@ -40,33 +33,12 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const { data: conversations, isFetching, error } = useUserConversations();
const { mutate: deleteConversation } = useDeleteConversation();
const { mutate: stopConversation } = useStopConversation();
const { mutate: updateConversation } = useUpdateConversation();
const handleDeleteProject = (conversationId: string) => {
setConfirmDeleteModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleStopConversation = (conversationId: string) => {
setConfirmStopModalVisible(true);
setSelectedConversationId(conversationId);
};
const handleConversationTitleChange = async (
conversationId: string,
newTitle: string,
) => {
updateConversation(
{ conversationId, newTitle },
{
onSuccess: () => {
displaySuccessToast(t(I18nKey.CONVERSATION$TITLE_UPDATED));
},
},
);
};
const handleConfirmDelete = () => {
if (selectedConversationId) {
deleteConversation(
@@ -82,21 +54,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
}
};
const handleConfirmStop = () => {
if (selectedConversationId) {
stopConversation(
{ conversationId: selectedConversationId },
{
onSuccess: () => {
if (selectedConversationId === currentConversationId) {
navigate("/");
}
},
},
);
}
};
return (
<div
ref={ref}
@@ -130,16 +87,8 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
<ConversationCard
isActive={isActive}
onDelete={() => handleDeleteProject(project.conversation_id)}
onStop={() => handleStopConversation(project.conversation_id)}
onChangeTitle={(title) =>
handleConversationTitleChange(project.conversation_id, title)
}
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}
@@ -159,16 +108,6 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
/>
)}
{confirmStopModalVisible && (
<ConfirmStopModal
onConfirm={() => {
handleConfirmStop();
setConfirmStopModalVisible(false);
}}
onCancel={() => setConfirmStopModalVisible(false)}
/>
)}
{confirmExitConversationModalVisible && (
<ExitConversationModal
onConfirm={() => {

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