mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 279b145fd9 | |||
| b83e2877ec | |||
| 7acee16de5 | |||
| 1e3f1de773 | |||
| bfe60d3bbf | |||
| ad75cd05d8 | |||
| 955f87561b | |||
| 1e5bff82f2 | |||
| ddf58da995 | |||
| b678d548c2 | |||
| a1d4d62f68 | |||
| 75e54e3552 | |||
| 6b211f3b29 | |||
| e208b64a95 | |||
| 555444f239 | |||
| d99c7827d8 | |||
| 5a8f08b4ef | |||
| 44fbd6c1b9 | |||
| 7e824ca5dc | |||
| 9a7002d817 | |||
| 6411d4df94 | |||
| c544ea1187 | |||
| 308d0e62ab | |||
| 9abd1714b9 | |||
| f1abe6c6af | |||
| 30b5ad1768 | |||
| 4ea3e4b1fd | |||
| 7049a3e918 | |||
| fa431fb956 | |||
| 2fc8ab2601 | |||
| 8e119c68ab | |||
| 8893f9364d | |||
| 727520f6ce | |||
| 898c3501dd | |||
| 4c81965c61 | |||
| 0f054c740c | |||
| 9bcf80dba5 | |||
| 2a98cd9338 | |||
| b31dbfc21a | |||
| 5d711d5576 | |||
| 3eb73de924 | |||
| 2e49f07451 | |||
| e51685dab4 | |||
| b85cc0c716 | |||
| 7ef1720b5d | |||
| a6385b4059 | |||
| 7cfe667a3f | |||
| 6e8be827b8 | |||
| 2ccc611e7c | |||
| 1f7dec4d94 | |||
| 966e4ae990 | |||
| 231019974c | |||
| d246ab1a21 | |||
| 15c207c401 | |||
| cf21cfed6c | |||
| 12d57df6ac | |||
| 3239eb4027 | |||
| 9be673d553 | |||
| 7272eae758 | |||
| ec670cd130 | |||
| 31702bf46b | |||
| 5894d2675e | |||
| 59a992c0fb | |||
| 1939bd0fda | |||
| 58e690ef75 | |||
| 97403dfbdb | |||
| 2fc31e96d0 | |||
| 6558b4f97d | |||
| 12d6da8130 | |||
| 38f2728cfa | |||
| fab48fe864 | |||
| a196881ab0 | |||
| ca2c9546ad | |||
| 704fc6dd69 | |||
| 6630d5dc4e | |||
| 0e7fefca7e | |||
| 4020448d64 | |||
| 2fdd4d084a | |||
| aba5d54a86 | |||
| 6710a39621 |
@@ -7,5 +7,8 @@ git config --global --add safe.directory "$(realpath .)"
|
||||
# Install `nc`
|
||||
sudo apt update && sudo apt install netcat -y
|
||||
|
||||
# Install `uv` and `uvx`
|
||||
wget -qO- https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Do common setup tasks
|
||||
source .openhands/setup.sh
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def find_version_references(directory: str) -> tuple[set[str], set[str]]:
|
||||
openhands_versions = set()
|
||||
runtime_versions = set()
|
||||
|
||||
version_pattern_openhands = re.compile(r'openhands:(\d{1})\.(\d{2})')
|
||||
version_pattern_runtime = re.compile(r'runtime:(\d{1})\.(\d{2})')
|
||||
|
||||
for root, _, files in os.walk(directory):
|
||||
# Skip .git directory and docs/build directory
|
||||
if '.git' in root or 'docs/build' in root:
|
||||
continue
|
||||
|
||||
for file in files:
|
||||
if file.endswith(
|
||||
('.md', '.yml', '.yaml', '.txt', '.html', '.py', '.js', '.ts')
|
||||
):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all openhands version references
|
||||
matches = version_pattern_openhands.findall(content)
|
||||
if matches:
|
||||
print(f'Found openhands version {matches} in {file_path}')
|
||||
openhands_versions.update(matches)
|
||||
|
||||
# Find all runtime version references
|
||||
matches = version_pattern_runtime.findall(content)
|
||||
if matches:
|
||||
print(f'Found runtime version {matches} in {file_path}')
|
||||
runtime_versions.update(matches)
|
||||
except Exception as e:
|
||||
print(f'Error reading {file_path}: {e}', file=sys.stderr)
|
||||
|
||||
return openhands_versions, runtime_versions
|
||||
|
||||
|
||||
def main():
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
||||
print(f'Checking version consistency in {repo_root}')
|
||||
openhands_versions, runtime_versions = find_version_references(repo_root)
|
||||
|
||||
print(f'Found openhands versions: {sorted(openhands_versions)}')
|
||||
print(f'Found runtime versions: {sorted(runtime_versions)}')
|
||||
|
||||
exit_code = 0
|
||||
|
||||
if len(openhands_versions) > 1:
|
||||
print('Error: Multiple openhands versions found:', file=sys.stderr)
|
||||
print('Found versions:', sorted(openhands_versions), file=sys.stderr)
|
||||
exit_code = 1
|
||||
elif len(openhands_versions) == 0:
|
||||
print('Warning: No openhands version references found', file=sys.stderr)
|
||||
|
||||
if len(runtime_versions) > 1:
|
||||
print('Error: Multiple runtime versions found:', file=sys.stderr)
|
||||
print('Found versions:', sorted(runtime_versions), file=sys.stderr)
|
||||
exit_code = 1
|
||||
elif len(runtime_versions) == 0:
|
||||
print('Warning: No runtime version references found', file=sys.stderr)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -13,9 +13,9 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:${SHORT_SHA}-nikolaik \
|
||||
--name openhands-app-${SHORT_SHA} \
|
||||
docker.all-hands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
docker.openhands.dev/openhands/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/OpenHands/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
|
||||
|
||||
@@ -71,6 +71,14 @@ jobs:
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Verify binary files exist
|
||||
run: |
|
||||
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
|
||||
echo "❌ No binaries found to upload!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Found binaries to upload."
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
|
||||
# Builds the runtime Docker images
|
||||
ghcr_build_runtime:
|
||||
name: Build Image
|
||||
name: Build Runtime Image
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
if: "!(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/ext-v'))"
|
||||
permissions:
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
test_runtime_root:
|
||||
name: RT Unit Tests (Root)
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
# We install pytest-xdist in order to run tests across CPUs
|
||||
poetry run pip install pytest-xdist
|
||||
|
||||
# Install to be able to retry on failures for flaky tests
|
||||
# Install to be able to retry on failures for flakey tests
|
||||
poetry run pip install pytest-rerunfailures
|
||||
|
||||
image_name=ghcr.io/${{ env.REPO_OWNER }}/runtime:${{ env.RELEVANT_SHA }}-${{ matrix.base_image.tag }}
|
||||
@@ -311,14 +311,14 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=false \
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
# Run unit tests with the Docker runtime Docker images as openhands user
|
||||
test_runtime_oh:
|
||||
name: RT Unit Tests (openhands)
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2204
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
needs: [ghcr_build_runtime, define-matrix]
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -370,7 +370,7 @@ jobs:
|
||||
SANDBOX_RUNTIME_CONTAINER_IMAGE=$image_name \
|
||||
TEST_IN_CI=true \
|
||||
RUN_AS_OPENHANDS=true \
|
||||
poetry run pytest -n 0 -raRs --reruns 2 --reruns-delay 5 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
poetry run pytest -n 5 -raRs --reruns 2 --reruns-delay 3 -s ./tests/runtime --ignore=tests/runtime/test_browsergym_envs.py --durations=10
|
||||
env:
|
||||
DEBUG: "1"
|
||||
|
||||
|
||||
@@ -90,16 +90,3 @@ jobs:
|
||||
- name: Run pre-commit hooks
|
||||
working-directory: ./openhands-cli
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Check version consistency across documentation
|
||||
check-version-consistency:
|
||||
name: Check version consistency
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Run version consistency check
|
||||
run: .github/scripts/check_version_consistency.py
|
||||
|
||||
@@ -48,7 +48,10 @@ jobs:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --with dev,test,runtime
|
||||
run: |
|
||||
poetry install --with dev,test,runtime
|
||||
poetry run pip install pytest-xdist
|
||||
poetry run pip install pytest-rerunfailures
|
||||
- name: Build Environment
|
||||
run: make build
|
||||
- name: Run Unit Tests
|
||||
@@ -56,7 +59,7 @@ jobs:
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
@@ -88,7 +91,7 @@ jobs:
|
||||
- name: Install Python dependencies using Poetry
|
||||
run: poetry install --with dev,test,runtime
|
||||
- name: Run Windows unit tests
|
||||
run: poetry run pytest -svv tests/unit/runtime/utils/test_windows_bash.py
|
||||
run: poetry run pytest -svv tests/runtime//test_windows_bash.py
|
||||
env:
|
||||
PYTHONPATH: ".;$env:PYTHONPATH"
|
||||
DEBUG: "1"
|
||||
@@ -173,9 +176,9 @@ jobs:
|
||||
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
|
||||
include-hidden-files: true
|
||||
|
||||
|
||||
coverage-comment:
|
||||
name: Coverage Comment
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-on-linux, test-enterprise, test-cli-python]
|
||||
|
||||
@@ -195,23 +198,8 @@ jobs:
|
||||
run: ln -sf openhands-cli/openhands_cli openhands_cli
|
||||
|
||||
- name: Coverage comment
|
||||
# In PR mode leaves a comment, otherwise records coverage in branch python-coverage-comment-action-data.
|
||||
id: coverage_comment
|
||||
uses: py-cov-action/python-coverage-comment-action@v3
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
MERGE_COVERAGE_FILES: true
|
||||
|
||||
- name: Enforce coverage
|
||||
# Fail if on PR AND there are uncovered lines AND diff coverage is less than total coverage.
|
||||
# To debug, try a step to log outputs like: `echo ${{ toJSON(steps.coverage_comment.outputs) }}`
|
||||
# Once we track base branch, reference_percent_covered will be better to use than new_percent_covered.
|
||||
if: ${{ github.event_name == 'pull_request' && fromJSON(steps.coverage_comment.outputs.diff_total_num_violations) > 0 && steps.coverage_comment.outputs.diff_total_percent_covered < steps.coverage_comment.outputs.new_percent_covered }}
|
||||
run: |
|
||||
echo "Coverage decreased, which is not allowed."
|
||||
echo "Please add some unit tests for the modified code."
|
||||
echo
|
||||
echo " diff_total_num_violations: ${{ steps.coverage_comment.outputs.diff_total_num_violations }}"
|
||||
echo " diff_total_percent_covered: ${{ steps.coverage_comment.outputs.diff_total_percent_covered}}"
|
||||
echo " new_percent_covered: ${{ steps.coverage_comment.outputs.new_percent_covered}}"
|
||||
exit 1
|
||||
|
||||
+1
-1
@@ -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/openhands/runtime:0.59-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:0.61-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Learn more at [docs.all-hands.dev](https://docs.all-hands.dev), or [sign up for
|
||||
|
||||
## ☁️ OpenHands Cloud
|
||||
The easiest way to get started with OpenHands is on [OpenHands Cloud](https://app.all-hands.dev),
|
||||
which comes with $20 in free credits for new users.
|
||||
which comes with $10 in free credits for new users.
|
||||
|
||||
## 💻 Running OpenHands Locally
|
||||
|
||||
@@ -66,10 +66,10 @@ See the [uv installation guide](https://docs.astral.sh/uv/getting-started/instal
|
||||
**Launch OpenHands**:
|
||||
```bash
|
||||
# Launch the GUI server
|
||||
uvx --python 3.12 --from openhands-ai openhands serve
|
||||
uvx --python 3.12 openhands serve
|
||||
|
||||
# Or launch the CLI
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
uvx --python 3.12 openhands
|
||||
```
|
||||
|
||||
You'll find OpenHands running at [http://localhost:3000](http://localhost:3000) (for GUI mode)!
|
||||
@@ -82,17 +82,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik
|
||||
docker pull docker.openhands.dev/openhands/runtime:0.61-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.59-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.openhands.dev/openhands/runtime:0.61-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.59
|
||||
docker.openhands.dev/openhands/openhands:0.61
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/openhands/runtime:0.61-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
+1
-1
@@ -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/openhands/runtime:0.59-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.openhands.dev/openhands/runtime:0.61-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:
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""add status and updated_at to callback
|
||||
|
||||
Revision ID: 080
|
||||
Revises: 079
|
||||
Create Date: 2025-11-05 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '080'
|
||||
down_revision: Union[str, None] = '079'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
class EventCallbackStatus(Enum):
|
||||
ACTIVE = 'ACTIVE'
|
||||
DISABLED = 'DISABLED'
|
||||
COMPLETED = 'COMPLETED'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
status = sa.Enum(EventCallbackStatus, name='eventcallbackstatus')
|
||||
status.create(op.get_bind(), checkfirst=True)
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column('status', status, nullable=False, server_default='ACTIVE'),
|
||||
)
|
||||
op.add_column(
|
||||
'event_callback',
|
||||
sa.Column(
|
||||
'updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()
|
||||
),
|
||||
)
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.String, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
op.drop_column('event_callback', 'status')
|
||||
op.drop_column('event_callback', 'updated_at')
|
||||
op.drop_index('ix_event_callback_result_event_id')
|
||||
op.drop_column('event_callback_result', 'event_id')
|
||||
op.add_column(
|
||||
'event_callback_result', sa.Column('event_id', sa.UUID, nullable=True)
|
||||
)
|
||||
op.create_index(
|
||||
op.f('ix_event_callback_result_event_id'),
|
||||
'event_callback_result',
|
||||
['event_id'],
|
||||
unique=False,
|
||||
)
|
||||
op.execute('DROP TYPE eventcallbackstatus')
|
||||
Generated
+295
-30
@@ -201,19 +201,20 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.65.0"
|
||||
version = "0.72.0"
|
||||
description = "The official Python library for the anthropic API"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "anthropic-0.65.0-py3-none-any.whl", hash = "sha256:ba9d9f82678046c74ddf5698ca06d9f5b0f599cfac922ab0d5921638eb448d98"},
|
||||
{file = "anthropic-0.65.0.tar.gz", hash = "sha256:6b6b6942574e54342050dfd42b8d856a8366b171daec147df3b80be4722733b9"},
|
||||
{file = "anthropic-0.72.0-py3-none-any.whl", hash = "sha256:0e9f5a7582f038cab8efbb4c959e49ef654a56bfc7ba2da51b5a7b8a84de2e4d"},
|
||||
{file = "anthropic-0.72.0.tar.gz", hash = "sha256:8971fe76dcffc644f74ac3883069beb1527641115ae0d6eb8fa21c1ce4082f7a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
anyio = ">=3.5.0,<5"
|
||||
distro = ">=1.7.0,<2"
|
||||
docstring-parser = ">=0.15,<1"
|
||||
google-auth = {version = ">=2,<3", extras = ["requests"], optional = true, markers = "extra == \"vertex\""}
|
||||
httpx = ">=0.25.0,<1"
|
||||
jiter = ">=0.4.0,<1"
|
||||
@@ -222,7 +223,7 @@ sniffio = "*"
|
||||
typing-extensions = ">=4.10,<5"
|
||||
|
||||
[package.extras]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"]
|
||||
aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.9)"]
|
||||
bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"]
|
||||
vertex = ["google-auth[requests] (>=2,<3)"]
|
||||
|
||||
@@ -681,31 +682,34 @@ crt = ["awscrt (==0.27.6)"]
|
||||
|
||||
[[package]]
|
||||
name = "browser-use"
|
||||
version = "0.7.10"
|
||||
version = "0.9.5"
|
||||
description = "Make websites accessible for AI agents"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.11"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "browser_use-0.7.10-py3-none-any.whl", hash = "sha256:669e12571a0c0c4c93e5fd26abf9e2534eb9bacbc510328aedcab795bd8906a9"},
|
||||
{file = "browser_use-0.7.10.tar.gz", hash = "sha256:f93ce59e06906c12d120360dee4aa33d83618ddf7c9a575dd0ac517d2de7ccbc"},
|
||||
{file = "browser_use-0.9.5-py3-none-any.whl", hash = "sha256:4a2e92847204d1ded269026a99cb0cc0e60e38bd2751fa3f58aedd78f00b4e67"},
|
||||
{file = "browser_use-0.9.5.tar.gz", hash = "sha256:f8285fe253b149d01769a7084883b4cf4db351e2f38e26302c157bcbf14a703f"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = "3.12.15"
|
||||
anthropic = ">=0.58.2,<1.0.0"
|
||||
anthropic = ">=0.68.1,<1.0.0"
|
||||
anyio = ">=4.9.0"
|
||||
authlib = ">=1.6.0"
|
||||
bubus = ">=1.5.6"
|
||||
cdp-use = ">=1.4.0"
|
||||
click = ">=8.1.8"
|
||||
cloudpickle = ">=3.1.1"
|
||||
google-api-core = ">=2.25.0"
|
||||
google-api-python-client = ">=2.174.0"
|
||||
google-auth = ">=2.40.3"
|
||||
google-auth-oauthlib = ">=1.2.2"
|
||||
google-genai = ">=1.29.0,<2.0.0"
|
||||
groq = ">=0.30.0"
|
||||
html2text = ">=2025.4.15"
|
||||
httpx = ">=0.28.1"
|
||||
inquirerpy = ">=0.3.4"
|
||||
markdownify = ">=1.2.0"
|
||||
mcp = ">=1.10.1"
|
||||
ollama = ">=0.5.1"
|
||||
openai = ">=1.99.2,<2.0.0"
|
||||
@@ -720,16 +724,20 @@ pypdf = ">=5.7.0"
|
||||
python-dotenv = ">=1.0.1"
|
||||
reportlab = ">=4.0.0"
|
||||
requests = ">=2.32.3"
|
||||
rich = ">=14.0.0"
|
||||
screeninfo = {version = ">=0.8.1", markers = "platform_system != \"darwin\""}
|
||||
typing-extensions = ">=4.12.2"
|
||||
uuid7 = ">=0.1.0"
|
||||
|
||||
[package.extras]
|
||||
all = ["agentmail (>=0.0.53)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "click (>=8.1.8)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
all = ["agentmail (==0.0.59)", "boto3 (>=1.38.45)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)", "oci (>=2.126.4)", "textual (>=3.2.0)"]
|
||||
aws = ["boto3 (>=1.38.45)"]
|
||||
cli = ["click (>=8.1.8)", "rich (>=14.0.0)", "textual (>=3.2.0)"]
|
||||
eval = ["anyio (>=4.9.0)", "browserbase (==1.4.0)", "datamodel-code-generator (>=0.26.0)", "hyperbrowser (==0.47.0)", "lmnr[all] (==0.7.10)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (>=0.0.53)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
cli = ["textual (>=3.2.0)"]
|
||||
cli-oci = ["oci (>=2.126.4)", "textual (>=3.2.0)"]
|
||||
code = ["matplotlib (>=3.9.0)", "numpy (>=2.3.2)", "pandas (>=2.2.0)", "tabulate (>=0.9.0)"]
|
||||
eval = ["anyio (>=4.9.0)", "datamodel-code-generator (>=0.26.0)", "lmnr[all] (==0.7.17)", "psutil (>=7.0.0)"]
|
||||
examples = ["agentmail (==0.0.59)", "botocore (>=1.37.23)", "imgcat (>=0.6.0)", "langchain-openai (>=0.3.26)"]
|
||||
oci = ["oci (>=2.126.4)"]
|
||||
video = ["imageio[ffmpeg] (>=2.37.0)", "numpy (>=2.3.2)"]
|
||||
|
||||
[[package]]
|
||||
@@ -3525,6 +3533,25 @@ files = [
|
||||
{file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inquirerpy"
|
||||
version = "0.3.4"
|
||||
description = "Python port of Inquirer.js (A collection of common interactive command-line user interfaces)"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4"},
|
||||
{file = "InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
pfzy = ">=0.3.1,<0.4.0"
|
||||
prompt-toolkit = ">=3.0.1,<4.0.0"
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "installer"
|
||||
version = "0.7.0"
|
||||
@@ -4580,6 +4607,62 @@ files = [
|
||||
{file = "llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lmnr"
|
||||
version = "0.7.20"
|
||||
description = "Python SDK for Laminar"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lmnr-0.7.20-py3-none-any.whl", hash = "sha256:5f9fa7444e6f96c25e097f66484ff29e632bdd1de0e9346948bf5595f4a8af38"},
|
||||
{file = "lmnr-0.7.20.tar.gz", hash = "sha256:1f484cd618db2d71af65f90a0b8b36d20d80dc91a5138b811575c8677bf7c4fd"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
grpcio = ">=1"
|
||||
httpx = ">=0.24.0"
|
||||
opentelemetry-api = ">=1.33.0"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.0"
|
||||
opentelemetry-exporter-otlp-proto-http = ">=1.33.0"
|
||||
opentelemetry-instrumentation = ">=0.54b0"
|
||||
opentelemetry-instrumentation-threading = ">=0.57b0"
|
||||
opentelemetry-sdk = ">=1.33.0"
|
||||
opentelemetry-semantic-conventions = ">=0.54b0"
|
||||
opentelemetry-semantic-conventions-ai = ">=0.4.13"
|
||||
orjson = ">=3.0.0"
|
||||
packaging = ">=22.0"
|
||||
pydantic = ">=2.0.3,<3.0.0"
|
||||
python-dotenv = ">=1.0"
|
||||
tenacity = ">=8.0"
|
||||
tqdm = ">=4.0"
|
||||
|
||||
[package.extras]
|
||||
alephalpha = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)"]
|
||||
all = ["opentelemetry-instrumentation-alephalpha (>=0.47.1)", "opentelemetry-instrumentation-bedrock (>=0.47.1)", "opentelemetry-instrumentation-chromadb (>=0.47.1)", "opentelemetry-instrumentation-cohere (>=0.47.1)", "opentelemetry-instrumentation-crewai (>=0.47.1)", "opentelemetry-instrumentation-haystack (>=0.47.1)", "opentelemetry-instrumentation-lancedb (>=0.47.1)", "opentelemetry-instrumentation-langchain (>=0.47.1)", "opentelemetry-instrumentation-llamaindex (>=0.47.1)", "opentelemetry-instrumentation-marqo (>=0.47.1)", "opentelemetry-instrumentation-mcp (>=0.47.1)", "opentelemetry-instrumentation-milvus (>=0.47.1)", "opentelemetry-instrumentation-mistralai (>=0.47.1)", "opentelemetry-instrumentation-ollama (>=0.47.1)", "opentelemetry-instrumentation-pinecone (>=0.47.1)", "opentelemetry-instrumentation-qdrant (>=0.47.1)", "opentelemetry-instrumentation-replicate (>=0.47.1)", "opentelemetry-instrumentation-sagemaker (>=0.47.1)", "opentelemetry-instrumentation-together (>=0.47.1)", "opentelemetry-instrumentation-transformers (>=0.47.1)", "opentelemetry-instrumentation-vertexai (>=0.47.1)", "opentelemetry-instrumentation-watsonx (>=0.47.1)", "opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
bedrock = ["opentelemetry-instrumentation-bedrock (>=0.47.1)"]
|
||||
chromadb = ["opentelemetry-instrumentation-chromadb (>=0.47.1)"]
|
||||
cohere = ["opentelemetry-instrumentation-cohere (>=0.47.1)"]
|
||||
crewai = ["opentelemetry-instrumentation-crewai (>=0.47.1)"]
|
||||
haystack = ["opentelemetry-instrumentation-haystack (>=0.47.1)"]
|
||||
lancedb = ["opentelemetry-instrumentation-lancedb (>=0.47.1)"]
|
||||
langchain = ["opentelemetry-instrumentation-langchain (>=0.47.1)"]
|
||||
llamaindex = ["opentelemetry-instrumentation-llamaindex (>=0.47.1)"]
|
||||
marqo = ["opentelemetry-instrumentation-marqo (>=0.47.1)"]
|
||||
mcp = ["opentelemetry-instrumentation-mcp (>=0.47.1)"]
|
||||
milvus = ["opentelemetry-instrumentation-milvus (>=0.47.1)"]
|
||||
mistralai = ["opentelemetry-instrumentation-mistralai (>=0.47.1)"]
|
||||
ollama = ["opentelemetry-instrumentation-ollama (>=0.47.1)"]
|
||||
pinecone = ["opentelemetry-instrumentation-pinecone (>=0.47.1)"]
|
||||
qdrant = ["opentelemetry-instrumentation-qdrant (>=0.47.1)"]
|
||||
replicate = ["opentelemetry-instrumentation-replicate (>=0.47.1)"]
|
||||
sagemaker = ["opentelemetry-instrumentation-sagemaker (>=0.47.1)"]
|
||||
together = ["opentelemetry-instrumentation-together (>=0.47.1)"]
|
||||
transformers = ["opentelemetry-instrumentation-transformers (>=0.47.1)"]
|
||||
vertexai = ["opentelemetry-instrumentation-vertexai (>=0.47.1)"]
|
||||
watsonx = ["opentelemetry-instrumentation-watsonx (>=0.47.1)"]
|
||||
weaviate = ["opentelemetry-instrumentation-weaviate (>=0.47.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "6.0.1"
|
||||
@@ -5737,7 +5820,7 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5758,14 +5841,14 @@ wsproto = ">=1.2.0"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.59.0"
|
||||
version = "0.0.0-post.5514+7c9e66194"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
@@ -5801,13 +5884,14 @@ jupyter_kernel_gateway = "*"
|
||||
kubernetes = "^33.1.0"
|
||||
libtmux = ">=0.46.2"
|
||||
litellm = ">=1.74.3, <1.78.0, !=1.64.4, !=1.67.*"
|
||||
lmnr = "^0.7.20"
|
||||
memory-profiler = "^0.61.0"
|
||||
numpy = "*"
|
||||
openai = "1.99.9"
|
||||
openhands-aci = "0.3.2"
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/agent-sdk.git", rev = "ce0a71af55dfce101f7419fbdb0116178f01e109", subdirectory = "openhands-tools"}
|
||||
openhands-agent-server = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-agent-server"}
|
||||
openhands-sdk = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-sdk"}
|
||||
openhands-tools = {git = "https://github.com/OpenHands/software-agent-sdk.git", rev = "d5995c31c55e488d4ab0372d292973bc6fad71f1", subdirectory = "openhands-tools"}
|
||||
opentelemetry-api = "^1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "^1.33.1"
|
||||
pathspec = "^0.12.1"
|
||||
@@ -5863,7 +5947,7 @@ url = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5875,6 +5959,7 @@ develop = false
|
||||
fastmcp = ">=2.11.3"
|
||||
httpx = ">=0.27.0"
|
||||
litellm = ">=1.77.7.dev9"
|
||||
lmnr = ">=0.7.20"
|
||||
pydantic = ">=2.11.7"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
@@ -5886,14 +5971,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-sdk"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.0.0a4"
|
||||
version = "1.0.0a5"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
@@ -5904,7 +5989,7 @@ develop = false
|
||||
[package.dependencies]
|
||||
bashlex = ">=0.18"
|
||||
binaryornot = ">=0.4.4"
|
||||
browser-use = ">=0.7.7"
|
||||
browser-use = ">=0.8.0"
|
||||
cachetools = "*"
|
||||
func-timeout = ">=4.3.5"
|
||||
libtmux = ">=0.46.2"
|
||||
@@ -5913,9 +5998,9 @@ pydantic = ">=2.11.7"
|
||||
|
||||
[package.source]
|
||||
type = "git"
|
||||
url = "https://github.com/OpenHands/agent-sdk.git"
|
||||
reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
resolved_reference = "ce0a71af55dfce101f7419fbdb0116178f01e109"
|
||||
url = "https://github.com/OpenHands/software-agent-sdk.git"
|
||||
reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
resolved_reference = "d5995c31c55e488d4ab0372d292973bc6fad71f1"
|
||||
subdirectory = "openhands-tools"
|
||||
|
||||
[[package]]
|
||||
@@ -5988,6 +6073,62 @@ opentelemetry-proto = "1.36.0"
|
||||
opentelemetry-sdk = ">=1.36.0,<1.37.0"
|
||||
typing-extensions = ">=4.6.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-exporter-otlp-proto-http"
|
||||
version = "1.36.0"
|
||||
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0-py3-none-any.whl", hash = "sha256:3d769f68e2267e7abe4527f70deb6f598f40be3ea34c6adc35789bea94a32902"},
|
||||
{file = "opentelemetry_exporter_otlp_proto_http-1.36.0.tar.gz", hash = "sha256:dd3637f72f774b9fc9608ab1ac479f8b44d09b6fb5b2f3df68a24ad1da7d356e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
googleapis-common-protos = ">=1.52,<2.0"
|
||||
opentelemetry-api = ">=1.15,<2.0"
|
||||
opentelemetry-exporter-otlp-proto-common = "1.36.0"
|
||||
opentelemetry-proto = "1.36.0"
|
||||
opentelemetry-sdk = ">=1.36.0,<1.37.0"
|
||||
requests = ">=2.7,<3.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation"
|
||||
version = "0.57b0"
|
||||
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_instrumentation-0.57b0-py3-none-any.whl", hash = "sha256:9109280f44882e07cec2850db28210b90600ae9110b42824d196de357cbddf7e"},
|
||||
{file = "opentelemetry_instrumentation-0.57b0.tar.gz", hash = "sha256:f2a30135ba77cdea2b0e1df272f4163c154e978f57214795d72f40befd4fcf05"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = ">=1.4,<2.0"
|
||||
opentelemetry-semantic-conventions = "0.57b0"
|
||||
packaging = ">=18.0"
|
||||
wrapt = ">=1.0.0,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-instrumentation-threading"
|
||||
version = "0.57b0"
|
||||
description = "Thread context propagation support for OpenTelemetry"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_instrumentation_threading-0.57b0-py3-none-any.whl", hash = "sha256:adfd64857c8c78d6111cf80552311e1713bad64272dd81abdd61f07b892a161b"},
|
||||
{file = "opentelemetry_instrumentation_threading-0.57b0.tar.gz", hash = "sha256:06fa4c98d6bfe4670e7532497670ac202db42afa647ff770aedce0e422421c6e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
opentelemetry-api = ">=1.12,<2.0"
|
||||
opentelemetry-instrumentation = "0.57b0"
|
||||
wrapt = ">=1.0.0,<2.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "1.36.0"
|
||||
@@ -6036,6 +6177,115 @@ files = [
|
||||
opentelemetry-api = "1.36.0"
|
||||
typing-extensions = ">=4.5.0"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions-ai"
|
||||
version = "0.4.13"
|
||||
description = "OpenTelemetry Semantic Conventions Extension for Large Language Models"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "opentelemetry_semantic_conventions_ai-0.4.13-py3-none-any.whl", hash = "sha256:883a30a6bb5deaec0d646912b5f9f6dcbb9f6f72557b73d0f2560bf25d13e2d5"},
|
||||
{file = "opentelemetry_semantic_conventions_ai-0.4.13.tar.gz", hash = "sha256:94efa9fb4ffac18c45f54a3a338ffeb7eedb7e1bb4d147786e77202e159f0036"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.4"
|
||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a69ab657a4e6733133a3dca82768f2f8b884043714e8d2b9ba9f52b6efef5c44"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3740bffd9816fc0326ddc406098a3a8f387e42223f5f455f2a02a9f834ead80c"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65fd2f5730b1bf7f350c6dc896173d3460d235c4be007af73986d7cd9a2acd23"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fdc3ae730541086158d549c97852e2eea6820665d4faf0f41bf99df41bc11ea"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10b4d65901da88845516ce9f7f9736f9638d19a1d483b3883dc0182e6e5edba"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb6a03a678085f64b97f9d4a9ae69376ce91a3a9e9b56a82b1580d8e1d501aff"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2c82e4f0b1c712477317434761fbc28b044c838b6b1240d895607441412371ac"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:d58c166a18f44cc9e2bad03a327dc2d1a3d2e85b847133cfbafd6bfc6719bd79"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:94f206766bf1ea30e1382e4890f763bd1eefddc580e08fec1ccdc20ddd95c827"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:41bf25fb39a34cf8edb4398818523277ee7096689db352036a9e8437f2f3ee6b"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-win32.whl", hash = "sha256:fa9627eba4e82f99ca6d29bc967f09aba446ee2b5a1ea728949ede73d313f5d3"},
|
||||
{file = "orjson-3.11.4-cp310-cp310-win_amd64.whl", hash = "sha256:23ef7abc7fca96632d8174ac115e668c1e931b8fe4dde586e92a500bf1914dcc"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5e59d23cd93ada23ec59a96f215139753fbfe3a4d989549bcb390f8c00370b39"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5c3aedecfc1beb988c27c79d52ebefab93b6c3921dbec361167e6559aba2d36d"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da9e5301f1c2caa2a9a4a303480d79c9ad73560b2e7761de742ab39fe59d9175"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8873812c164a90a79f65368f8f96817e59e35d0cc02786a5356f0e2abed78040"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5d7feb0741ebb15204e748f26c9638e6665a5fa93c37a2c73d64f1669b0ddc63"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ee5487fefee21e6910da4c2ee9eef005bee568a0879834df86f888d2ffbdd9"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d40d46f348c0321df01507f92b95a377240c4ec31985225a6668f10e2676f9a"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95713e5fc8af84d8edc75b785d2386f653b63d62b16d681687746734b4dfc0be"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ad73ede24f9083614d6c4ca9a85fe70e33be7bf047ec586ee2363bc7418fe4d7"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:842289889de515421f3f224ef9c1f1efb199a32d76d8d2ca2706fa8afe749549"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3b2427ed5791619851c52a1261b45c233930977e7de8cf36de05636c708fa905"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c36e524af1d29982e9b190573677ea02781456b2e537d5840e4538a5ec41907"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-win32.whl", hash = "sha256:87255b88756eab4a68ec61837ca754e5d10fa8bc47dc57f75cedfeaec358d54c"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-win_amd64.whl", hash = "sha256:e2d5d5d798aba9a0e1fede8d853fa899ce2cb930ec0857365f700dffc2c7af6a"},
|
||||
{file = "orjson-3.11.4-cp311-cp311-win_arm64.whl", hash = "sha256:6bb6bb41b14c95d4f2702bce9975fda4516f1db48e500102fc4d8119032ff045"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d4371de39319d05d3f482f372720b841c841b52f5385bd99c61ed69d55d9ab50"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e41fd3b3cac850eaae78232f37325ed7d7436e11c471246b87b2cd294ec94853"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:600e0e9ca042878c7fdf189cf1b028fe2c1418cc9195f6cb9824eb6ed99cb938"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7bbf9b333f1568ef5da42bc96e18bf30fd7f8d54e9ae066d711056add508e415"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4806363144bb6e7297b8e95870e78d30a649fdc4e23fc84daa80c8ebd366ce44"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad355e8308493f527d41154e9053b86a5be892b3b359a5c6d5d95cda23601cb2"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c8a7517482667fb9f0ff1b2f16fe5829296ed7a655d04d68cd9711a4d8a4e708"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97eb5942c7395a171cbfecc4ef6701fc3c403e762194683772df4c54cfbb2210"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:149d95d5e018bdd822e3f38c103b1a7c91f88d38a88aada5c4e9b3a73a244241"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:624f3951181eb46fc47dea3d221554e98784c823e7069edb5dbd0dc826ac909b"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:03bfa548cf35e3f8b3a96c4e8e41f753c686ff3d8e182ce275b1751deddab58c"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:525021896afef44a68148f6ed8a8bf8375553d6066c7f48537657f64823565b9"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-win32.whl", hash = "sha256:b58430396687ce0f7d9eeb3dd47761ca7d8fda8e9eb92b3077a7a353a75efefa"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-win_amd64.whl", hash = "sha256:c6dbf422894e1e3c80a177133c0dda260f81428f9de16d61041949f6a2e5c140"},
|
||||
{file = "orjson-3.11.4-cp312-cp312-win_arm64.whl", hash = "sha256:d38d2bc06d6415852224fcc9c0bfa834c25431e466dc319f0edd56cca81aa96e"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:2d6737d0e616a6e053c8b4acc9eccea6b6cce078533666f32d140e4f85002534"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:afb14052690aa328cc118a8e09f07c651d301a72e44920b887c519b313d892ff"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38aa9e65c591febb1b0aed8da4d469eba239d434c218562df179885c94e1a3ad"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f2cf4dfaf9163b0728d061bebc1e08631875c51cd30bf47cb9e3293bfbd7dcd5"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:89216ff3dfdde0e4070932e126320a1752c9d9a758d6a32ec54b3b9334991a6a"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9daa26ca8e97fae0ce8aa5d80606ef8f7914e9b129b6b5df9104266f764ce436"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c8b2769dc31883c44a9cd126560327767f848eb95f99c36c9932f51090bfce9"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1469d254b9884f984026bd9b0fa5bbab477a4bfe558bba6848086f6d43eb5e73"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:68e44722541983614e37117209a194e8c3ad07838ccb3127d96863c95ec7f1e0"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8e7805fda9672c12be2f22ae124dcd7b03928d6c197544fe12174b86553f3196"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:04b69c14615fb4434ab867bf6f38b2d649f6f300af30a6705397e895f7aec67a"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:639c3735b8ae7f970066930e58cf0ed39a852d417c24acd4a25fc0b3da3c39a6"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-win32.whl", hash = "sha256:6c13879c0d2964335491463302a6ca5ad98105fc5db3565499dcb80b1b4bd839"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-win_amd64.whl", hash = "sha256:09bf242a4af98732db9f9a1ec57ca2604848e16f132e3f72edfd3c5c96de009a"},
|
||||
{file = "orjson-3.11.4-cp313-cp313-win_arm64.whl", hash = "sha256:a85f0adf63319d6c1ba06fb0dbf997fced64a01179cf17939a6caca662bf92de"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:42d43a1f552be1a112af0b21c10a5f553983c2a0938d2bbb8ecd8bc9fb572803"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:26a20f3fbc6c7ff2cb8e89c4c5897762c9d88cf37330c6a117312365d6781d54"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e3f20be9048941c7ffa8fc523ccbd17f82e24df1549d1d1fe9317712d19938e"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aac364c758dc87a52e68e349924d7e4ded348dedff553889e4d9f22f74785316"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c54a6d76e3d741dcc3f2707f8eeb9ba2a791d3adbf18f900219b62942803b1"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f28485bdca8617b79d44627f5fb04336897041dfd9fa66d383a49d09d86798bc"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bfc2a484cad3585e4ba61985a6062a4c2ed5c7925db6d39f1fa267c9d166487f"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e34dbd508cb91c54f9c9788923daca129fe5b55c5b4eebe713bf5ed3791280cf"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b13c478fa413d4b4ee606ec8e11c3b2e52683a640b006bb586b3041c2ca5f606"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:724ca721ecc8a831b319dcd72cfa370cc380db0bf94537f08f7edd0a7d4e1780"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:977c393f2e44845ce1b540e19a786e9643221b3323dae190668a98672d43fb23"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e539e382cf46edec157ad66b0b0872a90d829a6b71f17cb633d6c160a223155"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-win32.whl", hash = "sha256:d63076d625babab9db5e7836118bdfa086e60f37d8a174194ae720161eb12394"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a54d6635fa3aaa438ae32e8570b9f0de36f3f6562c308d2a2a452e8b0592db1"},
|
||||
{file = "orjson-3.11.4-cp314-cp314-win_arm64.whl", hash = "sha256:78b999999039db3cf58f6d230f524f04f75f129ba3d1ca2ed121f8657e575d3d"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:405261b0a8c62bcbd8e2931c26fdc08714faf7025f45531541e2b29e544b545b"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:af02ff34059ee9199a3546f123a6ab4c86caf1708c79042caf0820dc290a6d4f"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b2eba969ea4203c177c7b38b36c69519e6067ee68c34dc37081fac74c796e10"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0baa0ea43cfa5b008a28d3c07705cf3ada40e5d347f0f44994a64b1b7b4b5350"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80fd082f5dcc0e94657c144f1b2a3a6479c44ad50be216cf0c244e567f5eae19"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e3704d35e47d5bee811fb1cbd8599f0b4009b14d451c4c57be5a7e25eb89a13"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caa447f2b5356779d914658519c874cf3b7629e99e63391ed519c28c8aea4919"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bba5118143373a86f91dadb8df41d9457498226698ebdf8e11cbb54d5b0e802d"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:622463ab81d19ef3e06868b576551587de8e4d518892d1afab71e0fbc1f9cffc"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3e0a700c4b82144b72946b6629968df9762552ee1344bfdb767fecdd634fbd5a"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6e18a5c15e764e5f3fc569b47872450b4bcea24f2a6354c0a0e95ad21045d5a9"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-win32.whl", hash = "sha256:fb1c37c71cad991ef4d89c7a634b5ffb4447dbd7ae3ae13e8f5ee7f1775e7ab1"},
|
||||
{file = "orjson-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:e2985ce8b8c42d00492d0ed79f2bd2b6460d00f2fa671dfde4bf2e02f49bf5c6"},
|
||||
{file = "orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "25.0"
|
||||
@@ -6252,6 +6502,21 @@ files = [
|
||||
[package.dependencies]
|
||||
ptyprocess = ">=0.5"
|
||||
|
||||
[[package]]
|
||||
name = "pfzy"
|
||||
version = "0.3.4"
|
||||
description = "Python port of the fzy fuzzy string matching algorithm"
|
||||
optional = false
|
||||
python-versions = ">=3.7,<4.0"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96"},
|
||||
{file = "pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "pg8000"
|
||||
version = "1.31.5"
|
||||
|
||||
@@ -50,7 +50,7 @@ SUBSCRIPTION_PRICE_DATA = {
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '20'))
|
||||
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', '10'))
|
||||
STRIPE_API_KEY = os.environ.get('STRIPE_API_KEY', None)
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', None)
|
||||
REQUIRE_PAYMENT = os.environ.get('REQUIRE_PAYMENT', '0') in ('1', 'true')
|
||||
|
||||
@@ -35,6 +35,7 @@ class SaasConversationStore(ConversationStore):
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
)
|
||||
|
||||
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
|
||||
@@ -123,6 +124,7 @@ class SaasConversationStore(ConversationStore):
|
||||
conversations = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.user_id == self.user_id)
|
||||
.filter(StoredConversationMetadata.conversation_version == 'V0')
|
||||
.order_by(StoredConversationMetadata.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit + 1)
|
||||
|
||||
@@ -243,7 +243,7 @@ async def test_update_settings_with_litellm_default(
|
||||
# Check that the URL and most of the JSON payload match what we expect
|
||||
assert call_args['json']['user_email'] == 'testy@tester.com'
|
||||
assert call_args['json']['models'] == []
|
||||
assert call_args['json']['max_budget'] == 20.0
|
||||
assert call_args['json']['max_budget'] == 10.0
|
||||
assert call_args['json']['user_id'] == 'user-id'
|
||||
assert call_args['json']['teams'] == ['test_team']
|
||||
assert call_args['json']['auto_create_key'] is True
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import argparse
|
||||
import fnmatch
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def find_final_reports(base_dir, pattern=None):
|
||||
base_path = Path(base_dir)
|
||||
if not base_path.exists():
|
||||
raise FileNotFoundError(f'Base directory does not exist: {base_dir}')
|
||||
|
||||
# Find all final_report.json files
|
||||
all_reports = list(base_path.rglob('final_report.json'))
|
||||
|
||||
if pattern is None:
|
||||
return all_reports
|
||||
|
||||
# Filter by pattern
|
||||
filtered_reports = []
|
||||
for report in all_reports:
|
||||
# Get relative path from base_dir for matching
|
||||
rel_path = report.relative_to(base_path)
|
||||
if fnmatch.fnmatch(str(rel_path), pattern):
|
||||
filtered_reports.append(report)
|
||||
|
||||
return filtered_reports
|
||||
|
||||
|
||||
def collect_resolved_ids(report_files):
|
||||
id_counter = Counter()
|
||||
|
||||
for report_file in report_files:
|
||||
with open(report_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
if 'resolved_ids' not in data:
|
||||
raise KeyError(f"'resolved_ids' key not found in {report_file}")
|
||||
resolved_ids = data['resolved_ids']
|
||||
id_counter.update(resolved_ids)
|
||||
|
||||
return id_counter
|
||||
|
||||
|
||||
def get_skip_ids(id_counter, threshold):
|
||||
return [id_str for id_str, count in id_counter.items() if count >= threshold]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Compute SKIP_IDS from resolved IDs in final_report.json files'
|
||||
)
|
||||
parser.add_argument(
|
||||
'threshold',
|
||||
type=int,
|
||||
help='Minimum number of times an ID must be resolved to be skipped',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--base-dir',
|
||||
default='evaluation/evaluation_outputs/outputs',
|
||||
help='Base directory to search for final_report.json files (default: evaluation/evaluation_outputs/outputs)',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--pattern',
|
||||
default=None,
|
||||
help='Glob pattern to filter paths (e.g., "*Multi-SWE-RL*/**/*gpt*")',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
report_files = find_final_reports(args.base_dir, args.pattern)
|
||||
id_counter = collect_resolved_ids(report_files)
|
||||
|
||||
skip_ids = get_skip_ids(id_counter, args.threshold)
|
||||
skip_ids = [s.replace('/', '__').replace(':pr-', '-') for s in skip_ids]
|
||||
skip_ids = ','.join(sorted(skip_ids))
|
||||
print(skip_ids)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -747,10 +747,14 @@ def filter_dataset(dataset: pd.DataFrame, filter_column: str) -> pd.DataFrame:
|
||||
subset = dataset[dataset[filter_column].isin(selected_ids)]
|
||||
logger.info(f'Retained {subset.shape[0]} tasks after filtering')
|
||||
return subset
|
||||
skip_ids = os.environ.get('SKIP_IDS', '').split(',')
|
||||
skip_ids = [id for id in os.environ.get('SKIP_IDS', '').split(',') if id]
|
||||
if len(skip_ids) > 0:
|
||||
logger.info(f'Dataset size before filtering: {dataset.shape[0]} tasks')
|
||||
logger.info(f'Filtering {len(skip_ids)} tasks from "SKIP_IDS"...')
|
||||
return dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'SKIP_IDS:\n{skip_ids}')
|
||||
filtered_dataset = dataset[~dataset[filter_column].isin(skip_ids)]
|
||||
logger.info(f'Dataset size after filtering: {filtered_dataset.shape[0]} tasks')
|
||||
return filtered_dataset
|
||||
return dataset
|
||||
|
||||
|
||||
@@ -768,6 +772,11 @@ if __name__ == '__main__':
|
||||
default='test',
|
||||
help='split to evaluate on',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--filter_dataset_after_sampling',
|
||||
action='store_true',
|
||||
help='if provided, filter dataset after sampling instead of before',
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# NOTE: It is preferable to load datasets from huggingface datasets and perform post-processing
|
||||
@@ -777,10 +786,24 @@ if __name__ == '__main__':
|
||||
logger.info(f'Loading dataset {args.dataset} with split {args.split} ')
|
||||
dataset = load_dataset('json', data_files=args.dataset)
|
||||
dataset = dataset[args.split]
|
||||
swe_bench_tests = filter_dataset(dataset.to_pandas(), 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
swe_bench_tests = dataset.to_pandas()
|
||||
|
||||
# Determine filter strategy based on flag
|
||||
filter_func = None
|
||||
if args.filter_dataset_after_sampling:
|
||||
# Pass filter as callback to apply after sampling
|
||||
def filter_func(df):
|
||||
return filter_dataset(df, 'instance_id')
|
||||
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks (filtering will occur after sampling)'
|
||||
)
|
||||
else:
|
||||
# Apply filter before sampling
|
||||
swe_bench_tests = filter_dataset(swe_bench_tests, 'instance_id')
|
||||
logger.info(
|
||||
f'Loaded dataset {args.dataset} with split {args.split}: {len(swe_bench_tests)} tasks'
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
@@ -810,7 +833,9 @@ if __name__ == '__main__':
|
||||
|
||||
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
|
||||
print(f'### OUTPUT FILE: {output_file} ###')
|
||||
instances = prepare_dataset(swe_bench_tests, output_file, args.eval_n_limit)
|
||||
instances = prepare_dataset(
|
||||
swe_bench_tests, output_file, args.eval_n_limit, filter_func=filter_func
|
||||
)
|
||||
|
||||
if len(instances) > 0 and not isinstance(
|
||||
instances['FAIL_TO_PASS'][instances['FAIL_TO_PASS'].index[0]], str
|
||||
|
||||
@@ -8,8 +8,14 @@
|
||||
MODEL=$1 # eg your llm config name in config.toml (eg: "llm.claude-3-5-sonnet-20241022-t05")
|
||||
EXP_NAME=$2 # "train-t05"
|
||||
EVAL_DATASET=$3 # path to original dataset (jsonl file)
|
||||
N_WORKERS=${4:-64}
|
||||
N_RUNS=${5:-1}
|
||||
MAX_ITER=$4
|
||||
N_WORKERS=${5:-64}
|
||||
N_RUNS=${6:-1}
|
||||
EVAL_LIMIT=${7:-}
|
||||
SKIP_IDS_THRESHOLD=$8
|
||||
SKIP_IDS_PATTERN=$9
|
||||
INPUT_SKIP_IDS=${10}
|
||||
FILTER_DATASET_AFTER_SAMPLING=${11:-}
|
||||
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
@@ -17,6 +23,7 @@ export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
echo "EVAL_DATASET: $EVAL_DATASET"
|
||||
echo "INPUT_SKIP_IDS: $INPUT_SKIP_IDS"
|
||||
# Generate DATASET path by adding _with_runtime_ before .jsonl extension
|
||||
DATASET="${EVAL_DATASET%.jsonl}_with_runtime_.jsonl" # path to converted dataset
|
||||
|
||||
@@ -35,9 +42,6 @@ else
|
||||
export SANDBOX_REMOTE_RUNTIME_API_URL="https://runtime.eval.all-hands.dev"
|
||||
fi
|
||||
|
||||
#EVAL_LIMIT=3000
|
||||
MAX_ITER=100
|
||||
|
||||
|
||||
# ===== Run inference =====
|
||||
source "evaluation/utils/version_control.sh"
|
||||
@@ -69,17 +73,52 @@ function run_eval() {
|
||||
--dataset $DATASET \
|
||||
--split $SPLIT"
|
||||
|
||||
# Conditionally add filter flag
|
||||
if [ "$FILTER_DATASET_AFTER_SAMPLING" = "true" ]; then
|
||||
COMMAND="$COMMAND --filter_dataset_after_sampling"
|
||||
fi
|
||||
|
||||
echo "Running command: $COMMAND"
|
||||
if [ -n "$EVAL_LIMIT" ]; then
|
||||
echo "EVAL_LIMIT: $EVAL_LIMIT"
|
||||
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
|
||||
fi
|
||||
|
||||
# Run the command
|
||||
eval $COMMAND
|
||||
}
|
||||
|
||||
for run_idx in $(seq 1 $N_RUNS); do
|
||||
if [ -n "$SKIP_IDS_THRESHOLD" ]; then
|
||||
echo "Computing SKIP_IDS for run $run_idx..."
|
||||
SKIP_CMD="poetry run python evaluation/benchmarks/multi_swe_bench/compute_skip_ids.py $SKIP_IDS_THRESHOLD"
|
||||
if [ -n "$SKIP_IDS_PATTERN" ]; then
|
||||
SKIP_CMD="$SKIP_CMD --pattern \"$SKIP_IDS_PATTERN\""
|
||||
fi
|
||||
COMPUTED_SKIP_IDS=$(eval $SKIP_CMD)
|
||||
SKIP_STATUS=$?
|
||||
if [ $SKIP_STATUS -ne 0 ]; then
|
||||
echo "ERROR: Skip IDs computation failed with exit code $SKIP_STATUS"
|
||||
exit $SKIP_STATUS
|
||||
fi
|
||||
echo "COMPUTED_SKIP_IDS: $COMPUTED_SKIP_IDS"
|
||||
else
|
||||
echo "SKIP_IDS_THRESHOLD not provided, skipping SKIP_IDS computation"
|
||||
COMPUTED_SKIP_IDS=""
|
||||
fi
|
||||
|
||||
# Concatenate COMPUTED_SKIP_IDS and INPUT_SKIP_IDS
|
||||
if [ -n "$COMPUTED_SKIP_IDS" ] && [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="${COMPUTED_SKIP_IDS},${INPUT_SKIP_IDS}"
|
||||
elif [ -n "$COMPUTED_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$COMPUTED_SKIP_IDS"
|
||||
elif [ -n "$INPUT_SKIP_IDS" ]; then
|
||||
export SKIP_IDS="$INPUT_SKIP_IDS"
|
||||
else
|
||||
unset SKIP_IDS
|
||||
fi
|
||||
|
||||
echo "FINAL SKIP_IDS: $SKIP_IDS"
|
||||
echo ""
|
||||
|
||||
while true; do
|
||||
echo "### Running inference... ###"
|
||||
|
||||
@@ -9,7 +9,7 @@ import time
|
||||
import traceback
|
||||
from contextlib import contextmanager
|
||||
from inspect import signature
|
||||
from typing import Any, Awaitable, Callable, TextIO
|
||||
from typing import Any, Awaitable, Callable, Optional, TextIO
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel
|
||||
@@ -222,6 +222,7 @@ def prepare_dataset(
|
||||
eval_n_limit: int,
|
||||
eval_ids: list[str] | None = None,
|
||||
skip_num: int | None = None,
|
||||
filter_func: Optional[Callable[[pd.DataFrame], pd.DataFrame]] = None,
|
||||
):
|
||||
assert 'instance_id' in dataset.columns, (
|
||||
"Expected 'instance_id' column in the dataset. You should define your own unique identifier for each instance and use it as the 'instance_id' column."
|
||||
@@ -265,6 +266,12 @@ def prepare_dataset(
|
||||
f'Randomly sampling {eval_n_limit} unique instances with random seed 42.'
|
||||
)
|
||||
|
||||
if filter_func is not None:
|
||||
dataset = filter_func(dataset)
|
||||
logger.info(
|
||||
f'Applied filter after sampling: {len(dataset)} instances remaining'
|
||||
)
|
||||
|
||||
def make_serializable(instance_dict: dict) -> dict:
|
||||
import numpy as np
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ vi.mock("#/hooks/use-auth-url", () => ({
|
||||
useAuthUrl: () => "https://gitlab.com/oauth/authorize",
|
||||
}));
|
||||
|
||||
// Mock the useTracking hook
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackLoginButtonClick: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AuthModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("location", { href: "" });
|
||||
|
||||
@@ -13,34 +13,6 @@ vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the custom hooks
|
||||
const mockStartConversationMutate = vi.fn();
|
||||
const mockStopConversationMutate = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-start-conversation", () => ({
|
||||
useUnifiedStartConversation: () => ({
|
||||
mutate: mockStartConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/mutation/use-unified-stop-conversation", () => ({
|
||||
useUnifiedStopConversation: () => ({
|
||||
mutate: mockStopConversationMutate,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-conversation-id", () => ({
|
||||
useConversationId: () => ({
|
||||
conversationId: "test-conversation-id",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => ({
|
||||
providers: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-task-polling", () => ({
|
||||
useTaskPolling: () => ({
|
||||
isTask: false,
|
||||
@@ -66,8 +38,12 @@ vi.mock("react-i18next", async () => {
|
||||
COMMON$SERVER_STOPPED: "Server Stopped",
|
||||
COMMON$ERROR: "Error",
|
||||
COMMON$STARTING: "Starting",
|
||||
COMMON$STOPPING: "Stopping...",
|
||||
COMMON$STOP_RUNTIME: "Stop Runtime",
|
||||
COMMON$START_RUNTIME: "Start Runtime",
|
||||
CONVERSATION$ERROR_STARTING_CONVERSATION:
|
||||
"Error starting conversation",
|
||||
CONVERSATION$READY: "Ready",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -79,10 +55,6 @@ vi.mock("react-i18next", async () => {
|
||||
});
|
||||
|
||||
describe("ServerStatus", () => {
|
||||
// Mock functions for handlers
|
||||
const mockHandleStop = vi.fn();
|
||||
const mockHandleResumeAgent = vi.fn();
|
||||
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
@@ -94,248 +66,91 @@ describe("ServerStatus", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render server status with different conversation statuses", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
it("should render server status with RUNNING conversation status", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
// Test RUNNING status
|
||||
const { rerender } = renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
// Test STOPPED status
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render server status with STOPPED conversation status", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STOPPED" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Server Stopped")).toBeInTheDocument();
|
||||
|
||||
// Test STARTING status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
|
||||
// Test null status (shows "Running" due to agent state being RUNNING)
|
||||
rerender(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu when clicked with RUNNING status", async () => {
|
||||
const user = userEvent.setup();
|
||||
it("should render STARTING status when agent state is LOADING", () => {
|
||||
mockAgentStore(AgentState.LOADING);
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render STARTING status when agent state is INIT", () => {
|
||||
mockAgentStore(AgentState.INIT);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="STARTING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Starting")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render ERROR status when agent state is ERROR", () => {
|
||||
mockAgentStore(AgentState.ERROR);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus="RUNNING" />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Error")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render STOPPING status when isPausing is true", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" isPausing={true} />,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should appear
|
||||
expect(
|
||||
screen.getByTestId("server-status-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show context menu when clicked with STOPPED status", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should appear
|
||||
expect(
|
||||
screen.getByTestId("server-status-context-menu"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show context menu when clicked with other statuses", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STARTING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
expect(statusContainer).toBeInTheDocument();
|
||||
|
||||
await user.click(statusContainer!);
|
||||
|
||||
// Context menu should not appear
|
||||
expect(
|
||||
screen.queryByTestId("server-status-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call stop conversation mutation when stop server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleStop.mockClear();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call start conversation mutation when start server is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Clear previous calls
|
||||
mockHandleResumeAgent.mockClear();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
expect(mockHandleResumeAgent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after stop server action", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="RUNNING"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Running").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const stopButton = screen.getByTestId("stop-server-button");
|
||||
await user.click(stopButton);
|
||||
|
||||
// Context menu should be closed (handled by the component)
|
||||
expect(mockHandleStop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should close context menu after start server action", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Mock agent store to return STOPPED state
|
||||
mockAgentStore(AgentState.STOPPED);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus="STOPPED"
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
);
|
||||
|
||||
const statusContainer = screen.getByText("Server Stopped").closest("div");
|
||||
await user.click(statusContainer!);
|
||||
|
||||
const startButton = screen.getByTestId("start-server-button");
|
||||
await user.click(startButton);
|
||||
|
||||
// Context menu should be closed
|
||||
expect(
|
||||
screen.queryByTestId("server-status-context-menu"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stopping...")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null conversation status", () => {
|
||||
// Mock agent store to return RUNNING state
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(<ServerStatus conversationStatus={null} />);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByText("Running")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatus
|
||||
conversationStatus={null}
|
||||
handleStop={mockHandleStop}
|
||||
handleResumeAgent={mockHandleResumeAgent}
|
||||
/>,
|
||||
<ServerStatus conversationStatus="RUNNING" className="custom-class" />,
|
||||
);
|
||||
|
||||
const statusText = screen.getByText("Running");
|
||||
expect(statusText).toBeInTheDocument();
|
||||
const container = screen.getByTestId("server-status");
|
||||
expect(container).toHaveClass("custom-class");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ServerStatusContextMenu", () => {
|
||||
// Helper function to mock agent state with specific state
|
||||
const mockAgentStore = (agentState: AgentState) => {
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: agentState,
|
||||
});
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
onClose: vi.fn(),
|
||||
conversationStatus: "RUNNING" as ConversationStatus,
|
||||
@@ -346,6 +161,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render stop server button when status is RUNNING", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -354,11 +171,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("stop-server-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop Runtime")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render start server button when status is STOPPED", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -367,11 +187,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("start-server-button")).toBeInTheDocument();
|
||||
expect(screen.getByText("Start Runtime")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render stop server button when onStopServer is not provided", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -379,10 +202,13 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render start server button when onStartServer is not provided", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -390,12 +216,14 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onStopServer when stop button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStopServer = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -414,6 +242,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
it("should call onStartServer when start button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStartServer = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -430,6 +259,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for stop server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -444,6 +275,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should render correct text content for start server button", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -459,6 +292,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
|
||||
it("should call onClose when context menu is closed", () => {
|
||||
const onClose = vi.fn();
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
@@ -475,6 +309,8 @@ describe("ServerStatusContextMenu", () => {
|
||||
});
|
||||
|
||||
it("should not render any buttons for other conversation statuses", () => {
|
||||
mockAgentStore(AgentState.RUNNING);
|
||||
|
||||
renderWithProviders(
|
||||
<ServerStatusContextMenu
|
||||
{...defaultProps}
|
||||
@@ -482,6 +318,7 @@ describe("ServerStatusContextMenu", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("server-status")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("stop-server-button")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("start-server-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+22
@@ -21,6 +21,7 @@ const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchRepositories = vi.fn();
|
||||
const mockUseCreateConversationAndSubscribeMultiple = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -47,6 +48,17 @@ vi.mock("#/hooks/query/use-search-repositories", () => ({
|
||||
useSearchRepositories: () => mockUseSearchRepositories(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-tracking", () => ({
|
||||
useTracking: () => ({
|
||||
trackEvent: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/use-create-conversation-and-subscribe-multiple", () => ({
|
||||
useCreateConversationAndSubscribeMultiple: () =>
|
||||
mockUseCreateConversationAndSubscribeMultiple(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
@@ -309,6 +321,16 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseCreateConversationAndSubscribeMultiple.mockReturnValue({
|
||||
createConversationAndSubscribe: vi.fn(({ onSuccessCallback }) => {
|
||||
// Immediately call the success callback to close the modal
|
||||
if (onSuccessCallback) {
|
||||
onSuccessCallback();
|
||||
}
|
||||
}),
|
||||
isPending: false,
|
||||
});
|
||||
|
||||
// Mock the search repositories hook to return repositories with OpenHands suffixes
|
||||
const mockSearchResults =
|
||||
getRepositoriesWithOpenHandsSuffix(mockRepositories);
|
||||
|
||||
@@ -188,172 +188,4 @@ describe("PaymentForm", () => {
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cancel Subscription", () => {
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
const cancelSubscriptionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"cancelSubscription",
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock active subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2024-12-31T23:59:59Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("should render cancel subscription button when user has active subscription", async () => {
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.getByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(cancelButton).toHaveTextContent("PAYMENT$CANCEL_SUBSCRIPTION");
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render cancel subscription button when user has no subscription", async () => {
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const cancelButton = screen.queryByTestId("cancel-subscription-button");
|
||||
expect(cancelButton).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show confirmation modal when cancel subscription button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Should show confirmation modal
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("PAYMENT$CANCEL_SUBSCRIPTION_TITLE"),
|
||||
).toBeInTheDocument();
|
||||
// The message should be rendered (either with Trans component or regular text)
|
||||
const modalContent = screen.getByTestId("cancel-subscription-modal");
|
||||
expect(modalContent).toBeInTheDocument();
|
||||
expect(screen.getByTestId("confirm-cancel-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("modal-cancel-button")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close modal when cancel button in modal is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Modal should be visible
|
||||
expect(
|
||||
screen.getByTestId("cancel-subscription-modal"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Click cancel in modal
|
||||
const modalCancelButton = screen.getByTestId("modal-cancel-button");
|
||||
await user.click(modalCancelButton);
|
||||
|
||||
// Modal should be closed
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call cancel subscription API when confirm button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Click confirm in modal
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Should call the cancel subscription API
|
||||
expect(cancelSubscriptionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should close modal after successful cancellation", async () => {
|
||||
const user = userEvent.setup();
|
||||
cancelSubscriptionSpy.mockResolvedValue({
|
||||
status: "success",
|
||||
message: "Subscription cancelled successfully",
|
||||
});
|
||||
renderPaymentForm();
|
||||
|
||||
const cancelButton = await screen.findByTestId(
|
||||
"cancel-subscription-button",
|
||||
);
|
||||
await user.click(cancelButton);
|
||||
|
||||
const confirmButton = screen.getByTestId("confirm-cancel-button");
|
||||
await user.click(confirmButton);
|
||||
|
||||
// Wait for API call to complete and modal to close
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("cancel-subscription-modal"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should show next billing date for active subscription", async () => {
|
||||
// Mock active subscription with end_at as next billing date
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: null,
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.getByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).toBeInTheDocument();
|
||||
// Check that it contains some date-related content (translation key or actual date)
|
||||
expect(nextBillingInfo).toHaveTextContent(
|
||||
/2025|PAYMENT.*BILLING.*DATE/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not show next billing date when subscription is cancelled", async () => {
|
||||
// Mock cancelled subscription
|
||||
getSubscriptionAccessSpy.mockResolvedValue({
|
||||
start_at: "2024-01-01T00:00:00Z",
|
||||
end_at: "2025-01-01T00:00:00Z",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
cancelled_at: "2024-06-15T10:30:00Z",
|
||||
stripe_subscription_id: "sub_123",
|
||||
});
|
||||
|
||||
renderPaymentForm();
|
||||
|
||||
await waitFor(() => {
|
||||
const nextBillingInfo = screen.queryByTestId("next-billing-date");
|
||||
expect(nextBillingInfo).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { JupyterEditor } from "#/components/features/jupyter/jupyter";
|
||||
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
// Mock the agent state hook
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JupyterEditor", () => {
|
||||
beforeEach(() => {
|
||||
// Reset the Zustand store before each test
|
||||
useJupyterStore.setState({
|
||||
cells: Array(20).fill({
|
||||
content: "Test cell content",
|
||||
type: "input",
|
||||
imageUrls: undefined,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have a scrollable container", () => {
|
||||
// Mock agent state to return RUNNING state (not in RUNTIME_INACTIVE_STATES)
|
||||
vi.mocked(useAgentState).mockReturnValue({
|
||||
curAgentState: AgentState.RUNNING,
|
||||
});
|
||||
|
||||
render(
|
||||
<div style={{ height: "100vh" }}>
|
||||
<JupyterEditor maxWidth={800} />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const container = screen.getByTestId("jupyter-container");
|
||||
expect(container).toHaveClass("flex-1 overflow-y-auto");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ const renderTerminal = (commands: Command[] = []) => {
|
||||
};
|
||||
|
||||
describe.skip("Terminal", () => {
|
||||
// Terminal is now read-only - no user input functionality
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
@@ -21,8 +22,6 @@ describe.skip("Terminal", () => {
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
loadAddon: vi.fn(),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
import { screen, waitFor, render, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
import {
|
||||
createMockMessageEvent,
|
||||
@@ -13,8 +14,12 @@ import {
|
||||
OptimisticUserMessageStoreComponent,
|
||||
ErrorMessageStoreComponent,
|
||||
} from "./helpers/websocket-test-components";
|
||||
import { ConversationWebSocketProvider } from "#/contexts/conversation-websocket-context";
|
||||
import {
|
||||
ConversationWebSocketProvider,
|
||||
useConversationWebSocket,
|
||||
} from "#/contexts/conversation-websocket-context";
|
||||
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
|
||||
// MSW WebSocket mock setup
|
||||
const { wsLink, server: mswServer } = conversationWebSocketTestSetup();
|
||||
@@ -417,7 +422,206 @@ describe("Conversation WebSocket Handler", () => {
|
||||
it.todo("should handle send attempts when disconnected");
|
||||
});
|
||||
|
||||
// 8. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
// 8. History Loading State Tests
|
||||
describe("History Loading State", () => {
|
||||
it("should track history loading state using event count from API", async () => {
|
||||
const conversationId = "test-conversation-with-history";
|
||||
|
||||
// Mock the event count API to return 3 events
|
||||
const expectedEventCount = 3;
|
||||
|
||||
// Create 3 mock events to simulate history
|
||||
const mockHistoryEvents = [
|
||||
createMockUserMessageEvent({ id: "history-event-1" }),
|
||||
createMockMessageEvent({ id: "history-event-2" }),
|
||||
createMockMessageEvent({ id: "history-event-3" }),
|
||||
];
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
<div data-testid="expected-event-count">{expectedEventCount}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty conversation history", async () => {
|
||||
const conversationId = "test-conversation-empty";
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(0);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ server }) => {
|
||||
server.connect();
|
||||
// No events sent for empty history
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Should quickly transition from loading to not loading when count is 0
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle history loading with large event count", async () => {
|
||||
const conversationId = "test-conversation-large-history";
|
||||
|
||||
// Create 50 mock events to simulate large history
|
||||
const expectedEventCount = 50;
|
||||
const mockHistoryEvents = Array.from({ length: 50 }, (_, i) =>
|
||||
createMockMessageEvent({ id: `history-event-${i + 1}` }),
|
||||
);
|
||||
|
||||
// Set up MSW to mock both the HTTP API and WebSocket connection
|
||||
mswServer.use(
|
||||
http.get("/api/v1/events/count", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
const conversationIdParam = url.searchParams.get(
|
||||
"conversation_id__eq",
|
||||
);
|
||||
|
||||
if (conversationIdParam === conversationId) {
|
||||
return HttpResponse.json(expectedEventCount);
|
||||
}
|
||||
|
||||
return HttpResponse.json(0);
|
||||
}),
|
||||
wsLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
// Send all history events
|
||||
mockHistoryEvents.forEach((event) => {
|
||||
client.send(JSON.stringify(event));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Create a test component that displays loading state
|
||||
const HistoryLoadingComponent = () => {
|
||||
const context = useConversationWebSocket();
|
||||
const { events } = useEventStore();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="is-loading-history">
|
||||
{context?.isLoadingHistory ? "true" : "false"}
|
||||
</div>
|
||||
<div data-testid="events-received">{events.length}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render with WebSocket context
|
||||
renderWithWebSocketContext(
|
||||
<HistoryLoadingComponent />,
|
||||
conversationId,
|
||||
`http://localhost:3000/api/conversations/${conversationId}`,
|
||||
);
|
||||
|
||||
// Initially should be loading history
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent("true");
|
||||
|
||||
// Wait for all events to be received
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("events-received")).toHaveTextContent("50");
|
||||
});
|
||||
|
||||
// Once all events are received, loading should be complete
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("is-loading-history")).toHaveTextContent(
|
||||
"false",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 9. Terminal I/O Tests (ExecuteBashAction and ExecuteBashObservation)
|
||||
describe("Terminal I/O Integration", () => {
|
||||
it("should append command to store when ExecuteBashAction event is received", async () => {
|
||||
const { createMockExecuteBashActionEvent } = await import(
|
||||
|
||||
@@ -38,8 +38,7 @@ export const createWebSocketTestSetup = (
|
||||
/**
|
||||
* Standard WebSocket test setup for conversation WebSocket handler tests
|
||||
* Updated to use the V1 WebSocket URL pattern: /sockets/events/{conversationId}
|
||||
* Uses a wildcard pattern to match any conversation ID
|
||||
*/
|
||||
export const conversationWebSocketTestSetup = () =>
|
||||
createWebSocketTestSetup(
|
||||
"ws://localhost:3000/sockets/events/test-conversation-default",
|
||||
);
|
||||
createWebSocketTestSetup("ws://localhost:3000/sockets/events/*");
|
||||
|
||||
@@ -35,13 +35,12 @@ function TestTerminalComponent() {
|
||||
}
|
||||
|
||||
describe("useTerminal", () => {
|
||||
// Terminal is read-only - no longer tests user input functionality
|
||||
const mockTerminal = vi.hoisted(() => ({
|
||||
loadAddon: vi.fn(),
|
||||
open: vi.fn(),
|
||||
write: vi.fn(),
|
||||
writeln: vi.fn(),
|
||||
onKey: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -268,7 +268,7 @@ describe("useWebSocket", () => {
|
||||
});
|
||||
|
||||
// onError handler should have been called
|
||||
expect(onErrorSpy).toHaveBeenCalledOnce();
|
||||
expect(onErrorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should provide sendMessage function to send messages to WebSocket", async () => {
|
||||
|
||||
@@ -4,14 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import LlmSettingsScreen from "#/routes/llm-settings";
|
||||
import SettingsService from "#/settings-service/settings-service.api";
|
||||
import OptionService from "#/api/option-service/option-service.api";
|
||||
import {
|
||||
MOCK_DEFAULT_USER_SETTINGS,
|
||||
resetTestHandlersMockSettings,
|
||||
} from "#/mocks/handlers";
|
||||
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
|
||||
import * as ToastHandlers from "#/utils/custom-toast-handlers";
|
||||
import BillingService from "#/api/billing-service/billing-service.api";
|
||||
|
||||
// Mock react-router hooks
|
||||
const mockUseSearchParams = vi.fn();
|
||||
@@ -25,12 +23,6 @@ vi.mock("#/hooks/query/use-is-authed", () => ({
|
||||
useIsAuthed: () => mockUseIsAuthed(),
|
||||
}));
|
||||
|
||||
// Mock useIsAllHandsSaaSEnvironment hook
|
||||
const mockUseIsAllHandsSaaSEnvironment = vi.fn();
|
||||
vi.mock("#/hooks/use-is-all-hands-saas-environment", () => ({
|
||||
useIsAllHandsSaaSEnvironment: () => mockUseIsAllHandsSaaSEnvironment(),
|
||||
}));
|
||||
|
||||
const renderLlmSettingsScreen = () =>
|
||||
render(<LlmSettingsScreen />, {
|
||||
wrapper: ({ children }) => (
|
||||
@@ -54,9 +46,6 @@ beforeEach(() => {
|
||||
|
||||
// Default mock for useIsAuthed - returns authenticated by default
|
||||
mockUseIsAuthed.mockReturnValue({ data: true, isLoading: false });
|
||||
|
||||
// Default mock for useIsAllHandsSaaSEnvironment - returns true for SaaS environment
|
||||
mockUseIsAllHandsSaaSEnvironment.mockReturnValue(true);
|
||||
});
|
||||
|
||||
describe("Content", () => {
|
||||
@@ -605,9 +594,14 @@ describe("Form submission", () => {
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
// Component automatically shows advanced view when advanced settings exist
|
||||
// Switch to basic view to test clearing advanced settings
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
await userEvent.click(advancedSwitch);
|
||||
|
||||
// Now we should be in basic view
|
||||
await screen.findByTestId("llm-settings-form-basic");
|
||||
|
||||
const provider = screen.getByTestId("llm-provider-input");
|
||||
const model = screen.getByTestId("llm-model-input");
|
||||
|
||||
@@ -731,405 +725,3 @@ describe("Status toasts", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("SaaS mode", () => {
|
||||
describe("SaaS subscription", () => {
|
||||
// Common mock configurations
|
||||
const MOCK_SAAS_CONFIG = {
|
||||
APP_MODE: "saas" as const,
|
||||
GITHUB_CLIENT_ID: "fake-github-client-id",
|
||||
POSTHOG_CLIENT_KEY: "fake-posthog-client-key",
|
||||
FEATURE_FLAGS: {
|
||||
ENABLE_BILLING: true,
|
||||
HIDE_LLM_SETTINGS: false,
|
||||
ENABLE_JIRA: false,
|
||||
ENABLE_JIRA_DC: false,
|
||||
ENABLE_LINEAR: false,
|
||||
},
|
||||
};
|
||||
|
||||
const MOCK_ACTIVE_SUBSCRIPTION = {
|
||||
start_at: "2024-01-01",
|
||||
end_at: "2024-12-31",
|
||||
created_at: "2024-01-01",
|
||||
};
|
||||
|
||||
it("should show upgrade banner and prevent all interactions for unsubscribed SaaS users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to ensure it's not called
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should have a clickable upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).not.toBeDisabled();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled or non-interactive
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
// Inputs should be disabled
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, so it's not visible in basic view
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to interact with inputs - they should not respond
|
||||
await userEvent.click(providerInput);
|
||||
await userEvent.type(apiKeyInput, "test-key");
|
||||
|
||||
// Values should not change
|
||||
expect(apiKeyInput).toHaveValue("");
|
||||
|
||||
// Try to submit form - should not call API
|
||||
await userEvent.click(submitButton);
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call subscription checkout API when upgrade button is clicked", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock the subscription checkout API call
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
createSubscriptionCheckoutSessionSpy.mockResolvedValue({});
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Click the upgrade button
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
await userEvent.click(upgradeButton);
|
||||
|
||||
// Should call the subscription checkout API
|
||||
expect(createSubscriptionCheckoutSessionSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should disable upgrade button for unauthenticated users in SaaS mode", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock subscription checkout API
|
||||
const createSubscriptionCheckoutSessionSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"createSubscriptionCheckoutSession",
|
||||
);
|
||||
|
||||
// Mock authentication to return false (unauthenticated) from the start
|
||||
mockUseIsAuthed.mockReturnValue({ data: false, isLoading: false });
|
||||
|
||||
// Mock settings to return default settings even when unauthenticated
|
||||
// This is necessary because the useSettings hook is disabled when user is not authenticated
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
|
||||
// Wait for either the settings screen or skeleton to appear
|
||||
await waitFor(() => {
|
||||
const settingsScreen = screen.queryByTestId("llm-settings-screen");
|
||||
const skeleton = screen.queryByTestId("app-settings-skeleton");
|
||||
expect(settingsScreen || skeleton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// If we get the skeleton, the test scenario isn't valid - skip the rest
|
||||
if (screen.queryByTestId("app-settings-skeleton")) {
|
||||
// For unauthenticated users, the settings don't load, so no upgrade banner is shown
|
||||
// This is the expected behavior - unauthenticated users see a skeleton loading state
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
return;
|
||||
}
|
||||
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Upgrade button should be disabled for unauthenticated users
|
||||
const upgradeButton = screen.getByRole("button", { name: /upgrade/i });
|
||||
expect(upgradeButton).toBeInTheDocument();
|
||||
expect(upgradeButton).toBeDisabled();
|
||||
|
||||
// Clicking disabled button should not call the API
|
||||
await userEvent.click(upgradeButton);
|
||||
expect(createSubscriptionCheckoutSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not show upgrade banner and allow form interaction for subscribed SaaS users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show upgrade banner
|
||||
expect(screen.queryByTestId("upgrade-banner")).not.toBeInTheDocument();
|
||||
|
||||
// Form should NOT be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).not.toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
|
||||
it("should not call save settings API when making changes in disabled form for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
// Mock saveSettings to track calls
|
||||
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify that basic form elements are disabled for unsubscribed users
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
const submitButton = screen.getByTestId("submit-button");
|
||||
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Try to submit the form - button should remain disabled
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
// Should NOT call save settings API for unsubscribed users
|
||||
expect(saveSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show backdrop overlay for unsubscribed users", async () => {
|
||||
// Mock SaaS mode without subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (no subscription)
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Should show backdrop overlay
|
||||
const backdrop = screen.getByTestId("settings-backdrop");
|
||||
expect(backdrop).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show backdrop overlay for subscribed users", async () => {
|
||||
// Mock SaaS mode with subscription
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return active subscription
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show backdrop overlay
|
||||
expect(screen.queryByTestId("settings-backdrop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display success toast when redirected back with ?checkout=success parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displaySuccessToastSpy = vi.spyOn(
|
||||
ToastHandlers,
|
||||
"displaySuccessToast",
|
||||
);
|
||||
|
||||
// Mock URL search params with ?checkout=success
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "success" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=success parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify success toast is displayed with correct message
|
||||
expect(displaySuccessToastSpy).toHaveBeenCalledWith(
|
||||
"SUBSCRIPTION$SUCCESS",
|
||||
);
|
||||
});
|
||||
|
||||
it("should display error toast when redirected back with ?checkout=cancel parameter", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(MOCK_ACTIVE_SUBSCRIPTION);
|
||||
|
||||
// Mock toast handler
|
||||
const displayErrorToastSpy = vi.spyOn(ToastHandlers, "displayErrorToast");
|
||||
|
||||
// Mock URL search params with ?checkout=cancel
|
||||
mockUseSearchParams.mockReturnValue([
|
||||
{
|
||||
get: (param: string) => (param === "checkout" ? "cancel" : null),
|
||||
},
|
||||
vi.fn(),
|
||||
]);
|
||||
|
||||
// Render component with checkout=cancel parameter
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Verify error toast is displayed with correct message
|
||||
expect(displayErrorToastSpy).toHaveBeenCalledWith("SUBSCRIPTION$FAILURE");
|
||||
});
|
||||
|
||||
it("should show upgrade banner when subscription is expired or disabled", async () => {
|
||||
// Mock SaaS mode
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(MOCK_SAAS_CONFIG);
|
||||
|
||||
// Mock subscription access to return null (expired/disabled subscriptions return null from backend)
|
||||
// The backend only returns active subscriptions within their validity period
|
||||
const getSubscriptionAccessSpy = vi.spyOn(
|
||||
BillingService,
|
||||
"getSubscriptionAccess",
|
||||
);
|
||||
getSubscriptionAccessSpy.mockResolvedValue(null);
|
||||
|
||||
renderLlmSettingsScreen();
|
||||
await screen.findByTestId("llm-settings-screen");
|
||||
|
||||
// Wait for subscription data to load
|
||||
await waitFor(() => {
|
||||
expect(getSubscriptionAccessSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should show upgrade banner for expired/disabled subscriptions (when API returns null)
|
||||
expect(screen.getByTestId("upgrade-banner")).toBeInTheDocument();
|
||||
|
||||
// Form should be disabled
|
||||
const form = screen.getByTestId("llm-settings-form-basic");
|
||||
expect(form).toHaveAttribute("aria-disabled", "true");
|
||||
|
||||
// All form inputs should be disabled
|
||||
const providerInput = screen.getByTestId("llm-provider-input");
|
||||
const modelInput = screen.getByTestId("llm-model-input");
|
||||
const apiKeyInput = screen.getByTestId("llm-api-key-input");
|
||||
const advancedSwitch = screen.getByTestId("advanced-settings-switch");
|
||||
|
||||
expect(providerInput).toBeDisabled();
|
||||
expect(modelInput).toBeDisabled();
|
||||
expect(apiKeyInput).toBeDisabled();
|
||||
expect(advancedSwitch).toBeDisabled();
|
||||
|
||||
// Confirmation mode switch is in advanced view, which can't be accessed when form is disabled
|
||||
expect(
|
||||
screen.queryByTestId("enable-confirmation-mode-switch"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ 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: {
|
||||
@@ -21,14 +20,6 @@ vi.mock("#/state/command-store", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/jupyter-store", () => ({
|
||||
useJupyterStore: {
|
||||
getState: () => ({
|
||||
appendJupyterInput: mockAppendJupyterInput,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/state/metrics-slice", () => ({
|
||||
setMetrics: vi.fn(),
|
||||
}));
|
||||
@@ -63,10 +54,9 @@ describe("handleActionMessage", () => {
|
||||
// Check that appendInput was called with the command
|
||||
expect(mockAppendInput).toHaveBeenCalledWith("ls -la");
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RUN_IPYTHON actions by adding input to Jupyter", async () => {
|
||||
it("should handle RUN_IPYTHON actions as no-op (Jupyter removed)", async () => {
|
||||
const { handleActionMessage } = await import("#/services/actions");
|
||||
|
||||
const ipythonAction: ActionMessage = {
|
||||
@@ -84,10 +74,7 @@ describe("handleActionMessage", () => {
|
||||
// Handle the action
|
||||
handleActionMessage(ipythonAction);
|
||||
|
||||
// Check that appendJupyterInput was called with the code
|
||||
expect(mockAppendJupyterInput).toHaveBeenCalledWith(
|
||||
"print('Hello from Jupyter!')",
|
||||
);
|
||||
// Jupyter functionality has been removed, so nothing should be called
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -112,6 +99,5 @@ describe("handleActionMessage", () => {
|
||||
// Check that nothing was dispatched or called
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(mockAppendInput).not.toHaveBeenCalled();
|
||||
expect(mockAppendJupyterInput).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+31
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getStatusCode, getIndicatorColor, IndicatorColor } from "../status";
|
||||
import { getStatusCode, getIndicatorColor, IndicatorColor } from "#/utils/status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -87,6 +87,36 @@ describe("getStatusCode", () => {
|
||||
// Should return runtime status since no agent state
|
||||
expect(result).toBe("STATUS$STARTING_RUNTIME");
|
||||
});
|
||||
|
||||
it("should prioritize task ERROR status over websocket CONNECTING state", () => {
|
||||
// Test case: Task has errored but websocket is still trying to connect
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTING", // webSocketStatus (stuck connecting)
|
||||
null, // conversationStatus
|
||||
null, // runtimeStatus
|
||||
AgentState.LOADING, // agentState
|
||||
"ERROR", // taskStatus (ERROR)
|
||||
);
|
||||
|
||||
// Should return error message, not "Connecting..."
|
||||
expect(result).toBe(I18nKey.AGENT_STATUS$ERROR_OCCURRED);
|
||||
});
|
||||
|
||||
it("should show Connecting when task is working and websocket is connecting", () => {
|
||||
// Test case: Task is in progress and websocket is connecting normally
|
||||
const result = getStatusCode(
|
||||
{ id: "", message: "", type: "info", status_update: true }, // statusMessage
|
||||
"CONNECTING", // webSocketStatus
|
||||
null, // conversationStatus
|
||||
null, // runtimeStatus
|
||||
AgentState.LOADING, // agentState
|
||||
"WORKING", // taskStatus (in progress)
|
||||
);
|
||||
|
||||
// Should show connecting message since task hasn't errored
|
||||
expect(result).toBe(I18nKey.CHAT_INTERFACE$CONNECTING);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getIndicatorColor", () => {
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.61.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.61.0",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.8.4",
|
||||
"@heroui/use-infinite-scroll": "^2.2.11",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.59.0",
|
||||
"version": "0.61.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -187,7 +187,7 @@ class ConversationService {
|
||||
static async getRuntimeId(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const url = `${this.getConversationUrl(conversationId)}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url, {
|
||||
headers: this.getConversationHeaders(),
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { openHands } from "../open-hands-axios";
|
||||
import { ConversationTrigger, GetVSCodeUrlResponse } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
V1SendMessageRequest,
|
||||
V1SendMessageResponse,
|
||||
@@ -13,21 +14,6 @@ import type {
|
||||
} from "./v1-conversation-service.types";
|
||||
|
||||
class V1ConversationService {
|
||||
/**
|
||||
* Build headers for V1 API requests that require session authentication
|
||||
* @param sessionApiKey Session API key for authentication
|
||||
* @returns Headers object with X-Session-API-Key if provided
|
||||
*/
|
||||
private static buildSessionHeaders(
|
||||
sessionApiKey?: string | null,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (sessionApiKey) {
|
||||
headers["X-Session-API-Key"] = sessionApiKey;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
@@ -160,7 +146,7 @@ class V1ConversationService {
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<GetVSCodeUrlResponse> {
|
||||
const url = this.buildRuntimeUrl(conversationUrl, "/api/vscode/url");
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns {url: '...'} instead of {vscode_url: '...'}
|
||||
// Map it to match the expected interface
|
||||
@@ -188,7 +174,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/pause`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@@ -216,7 +202,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/conversations/${conversationId}/run`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.post<{ success: boolean }>(
|
||||
url,
|
||||
@@ -226,36 +212,6 @@ class V1ConversationService {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to pause
|
||||
* @returns Success response
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*
|
||||
* @param sandboxId The sandbox ID to resume
|
||||
* @returns Success response
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 app conversations by their IDs
|
||||
* Returns null for any missing conversations
|
||||
@@ -305,7 +261,7 @@ class V1ConversationService {
|
||||
conversationUrl,
|
||||
`/api/file/upload/${encodedPath}`,
|
||||
);
|
||||
const headers = this.buildSessionHeaders(sessionApiKey);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Create FormData with the file
|
||||
const formData = new FormData();
|
||||
@@ -319,6 +275,19 @@ class V1ConversationService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the conversation config (runtime_id) for a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @returns Object containing runtime_id
|
||||
*/
|
||||
static async getConversationConfig(
|
||||
conversationId: string,
|
||||
): Promise<{ runtime_id: string }> {
|
||||
const url = `/api/conversations/${conversationId}/config`;
|
||||
const { data } = await openHands.get<{ runtime_id: string }>(url);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1ConversationService;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ConversationTrigger } from "../open-hands.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { V1SandboxStatus } from "../sandbox-service/sandbox-service.types";
|
||||
|
||||
// V1 API Types for requests
|
||||
// Note: This represents the serialized API format, not the internal TextContent/ImageContent types
|
||||
@@ -64,14 +65,7 @@ export interface V1AppConversationStartTaskPage {
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export type V1AgentExecutionStatus =
|
||||
export type V1ConversationExecutionStatus =
|
||||
| "RUNNING"
|
||||
| "AWAITING_USER_INPUT"
|
||||
| "AWAITING_USER_CONFIRMATION"
|
||||
@@ -94,7 +88,7 @@ export interface V1AppConversation {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sandbox_status: V1SandboxStatus;
|
||||
agent_status: V1AgentExecutionStatus | null;
|
||||
execution_status: V1ConversationExecutionStatus | null;
|
||||
conversation_url: string | null;
|
||||
session_api_key: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import type {
|
||||
ConfirmationResponseRequest,
|
||||
ConfirmationResponseResponse,
|
||||
} from "./event-service.types";
|
||||
import { openHands } from "../open-hands-axios";
|
||||
|
||||
class EventService {
|
||||
/**
|
||||
* Respond to a confirmation request in a V1 conversation
|
||||
* @param conversationId The conversation ID
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param request The confirmation response request
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @returns The confirmation response
|
||||
*/
|
||||
static async respondToConfirmation(
|
||||
conversationId: string,
|
||||
conversationUrl: string,
|
||||
request: ConfirmationResponseRequest,
|
||||
sessionApiKey?: string | null,
|
||||
): Promise<ConfirmationResponseResponse> {
|
||||
// Build the runtime URL using the conversation URL
|
||||
const runtimeUrl = buildHttpBaseUrl(conversationUrl);
|
||||
|
||||
// Build session headers for authentication
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// Make the API call to the runtime endpoint
|
||||
const { data } = await axios.post<ConfirmationResponseResponse>(
|
||||
`${runtimeUrl}/api/conversations/${conversationId}/events/respond_to_confirmation`,
|
||||
request,
|
||||
{ headers },
|
||||
);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getEventCount(conversationId: string): Promise<number> {
|
||||
const params = new URLSearchParams();
|
||||
params.append("conversation_id__eq", conversationId);
|
||||
const { data } = await openHands.get<number>(
|
||||
`/api/v1/events/count?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
export default EventService;
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface ConfirmationResponseRequest {
|
||||
accept: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ConfirmationResponseResponse {
|
||||
success: boolean;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import axios from "axios";
|
||||
import { buildHttpBaseUrl } from "#/utils/websocket-url";
|
||||
import { buildSessionHeaders } from "#/utils/utils";
|
||||
import { mapV1ToV0Status } from "#/utils/git-status-mapper";
|
||||
import type {
|
||||
GitChange,
|
||||
GitChangeDiff,
|
||||
V1GitChangeStatus,
|
||||
} from "../open-hands.types";
|
||||
|
||||
interface V1GitChange {
|
||||
status: V1GitChangeStatus;
|
||||
path: string;
|
||||
}
|
||||
|
||||
class V1GitService {
|
||||
/**
|
||||
* Build the full URL for V1 runtime-specific endpoints
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param path The API path (e.g., "/api/git/changes")
|
||||
* @returns Full URL to the runtime endpoint
|
||||
*/
|
||||
private static buildRuntimeUrl(
|
||||
conversationUrl: string | null | undefined,
|
||||
path: string,
|
||||
): string {
|
||||
const baseUrl = buildHttpBaseUrl(conversationUrl);
|
||||
return `${baseUrl}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git changes for a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/changes/{path}
|
||||
* Maps V1 status types (ADDED, DELETED, etc.) to V0 format (A, D, etc.)
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @param path The git repository path (e.g., /workspace/project or /workspace/project/OpenHands)
|
||||
* @returns List of git changes with V0-compatible status types
|
||||
*/
|
||||
static async getGitChanges(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChange[]> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/changes/${encodedPath}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
// V1 API returns V1GitChangeStatus types, we need to map them to V0 format
|
||||
const { data } = await axios.get<V1GitChange[]>(url, { headers });
|
||||
|
||||
// Map V1 statuses to V0 format for compatibility
|
||||
return data.map((change) => ({
|
||||
status: mapV1ToV0Status(change.status),
|
||||
path: change.path,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get git change diff for a specific file in a V1 conversation
|
||||
* Uses the agent server endpoint: GET /api/git/diff/{path}
|
||||
*
|
||||
* @param conversationUrl The conversation URL (e.g., "http://localhost:54928/api/conversations/...")
|
||||
* @param sessionApiKey Session API key for authentication (required for V1)
|
||||
* @param path The file path to get diff for
|
||||
* @returns Git change diff
|
||||
*/
|
||||
static async getGitChangeDiff(
|
||||
conversationUrl: string | null | undefined,
|
||||
sessionApiKey: string | null | undefined,
|
||||
path: string,
|
||||
): Promise<GitChangeDiff> {
|
||||
const encodedPath = encodeURIComponent(path);
|
||||
const url = this.buildRuntimeUrl(
|
||||
conversationUrl,
|
||||
`/api/git/diff/${encodedPath}`,
|
||||
);
|
||||
const headers = buildSessionHeaders(sessionApiKey);
|
||||
|
||||
const { data } = await axios.get<GitChangeDiff>(url, { headers });
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default V1GitService;
|
||||
@@ -84,8 +84,13 @@ export interface ResultSet<T> {
|
||||
next_page_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use V1GitChangeStatus for new code. This type is maintained for backward compatibility with V0 API.
|
||||
*/
|
||||
export type GitChangeStatus = "M" | "A" | "D" | "R" | "U";
|
||||
|
||||
export type V1GitChangeStatus = "MOVED" | "ADDED" | "DELETED" | "UPDATED";
|
||||
|
||||
export interface GitChange {
|
||||
status: GitChangeStatus;
|
||||
path: string;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// sandbox-service.api.ts
|
||||
// This file contains API methods for /api/v1/sandboxes endpoints.
|
||||
|
||||
import { openHands } from "../open-hands-axios";
|
||||
import type { V1SandboxInfo } from "./sandbox-service.types";
|
||||
|
||||
export class SandboxService {
|
||||
/**
|
||||
* Pause a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/pause endpoint
|
||||
*/
|
||||
static async pauseSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/pause`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a V1 sandbox
|
||||
* Calls the /api/v1/sandboxes/{id}/resume endpoint
|
||||
*/
|
||||
static async resumeSandbox(sandboxId: string): Promise<{ success: boolean }> {
|
||||
const { data } = await openHands.post<{ success: boolean }>(
|
||||
`/api/v1/sandboxes/${sandboxId}/resume`,
|
||||
{},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch get V1 sandboxes by their IDs
|
||||
* Returns null for any missing sandboxes
|
||||
*/
|
||||
static async batchGetSandboxes(
|
||||
ids: string[],
|
||||
): Promise<(V1SandboxInfo | null)[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (ids.length > 100) {
|
||||
throw new Error("Cannot request more than 100 sandboxes at once");
|
||||
}
|
||||
const params = new URLSearchParams();
|
||||
ids.forEach((id) => params.append("id", id));
|
||||
const { data } = await openHands.get<(V1SandboxInfo | null)[]>(
|
||||
`/api/v1/sandboxes?${params.toString()}`,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// sandbox-service.types.ts
|
||||
// This file contains types for Sandbox API.
|
||||
|
||||
export type V1SandboxStatus =
|
||||
| "MISSING"
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "PAUSED";
|
||||
|
||||
export interface V1ExposedUrl {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface V1SandboxInfo {
|
||||
id: string;
|
||||
created_by_user_id: string | null;
|
||||
sandbox_spec_id: string;
|
||||
status: V1SandboxStatus;
|
||||
session_api_key: string | null;
|
||||
exposed_urls: V1ExposedUrl[] | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
|
||||
function getEntryPoint(
|
||||
hasRepository: boolean | null,
|
||||
@@ -64,8 +65,10 @@ export function ChatInterface() {
|
||||
const { errorMessage } = useErrorMessageStore();
|
||||
const { isLoadingMessages } = useWsClient();
|
||||
const { isTask } = useTaskPolling();
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -94,17 +97,43 @@ export function ChatInterface() {
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Track when we should show V1 messages (after DOM has rendered)
|
||||
const [showV1Messages, setShowV1Messages] = React.useState(false);
|
||||
const prevV1LoadingRef = React.useRef(
|
||||
conversationWebSocket?.isLoadingHistory,
|
||||
);
|
||||
|
||||
// Wait for DOM to render before showing V1 messages
|
||||
React.useEffect(() => {
|
||||
const wasLoading = prevV1LoadingRef.current;
|
||||
const isLoading = conversationWebSocket?.isLoadingHistory;
|
||||
|
||||
if (wasLoading && !isLoading) {
|
||||
// Loading just finished - wait for next frame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
setShowV1Messages(true);
|
||||
});
|
||||
} else if (isLoading) {
|
||||
// Reset when loading starts
|
||||
setShowV1Messages(false);
|
||||
}
|
||||
|
||||
prevV1LoadingRef.current = isLoading;
|
||||
}, [conversationWebSocket?.isLoadingHistory]);
|
||||
|
||||
// Filter V0 events
|
||||
const v0Events = storeEvents
|
||||
.filter(isV0Event)
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
@@ -202,7 +231,7 @@ export function ChatInterface() {
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
@@ -228,6 +257,14 @@ export function ChatInterface() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(conversationWebSocket?.isLoadingHistory || !showV1Messages) &&
|
||||
isV1Conversation &&
|
||||
!isTask && (
|
||||
<div className="flex justify-center">
|
||||
<LoadingSpinner size="small" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoadingMessages && v0UserEventsExist && (
|
||||
<V0Messages
|
||||
messages={v0Events}
|
||||
@@ -237,13 +274,8 @@ export function ChatInterface() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{v1UserEventsExist && (
|
||||
<V1Messages
|
||||
messages={v1Events}
|
||||
isAwaitingUserConfirmation={
|
||||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION
|
||||
}
|
||||
/>
|
||||
{showV1Messages && v1UserEventsExist && (
|
||||
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { ServerStatus } from "#/components/features/controls/server-status";
|
||||
import { AgentStatus } from "#/components/features/controls/agent-status";
|
||||
import { Tools } from "../../controls/tools";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useSendMessage } from "#/hooks/use-send-message";
|
||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||
@@ -14,32 +10,23 @@ import { useV1PauseConversation } from "#/hooks/mutation/use-v1-pause-conversati
|
||||
import { useV1ResumeConversation } from "#/hooks/mutation/use-v1-resume-conversation";
|
||||
|
||||
interface ChatInputActionsProps {
|
||||
conversationStatus: ConversationStatus | null;
|
||||
disabled: boolean;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ChatInputActions({
|
||||
conversationStatus,
|
||||
disabled,
|
||||
handleResumeAgent,
|
||||
}: ChatInputActionsProps) {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const pauseConversationSandboxMutation = useUnifiedPauseConversationSandbox();
|
||||
const resumeConversationSandboxMutation =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const v1PauseConversationMutation = useV1PauseConversation();
|
||||
const v1ResumeConversationMutation = useV1ResumeConversation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { providers } = useUserProviders();
|
||||
const { send } = useSendMessage();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const handleStopClick = () => {
|
||||
pauseConversationSandboxMutation.mutate({ conversationId });
|
||||
};
|
||||
|
||||
const handlePauseAgent = () => {
|
||||
if (isV1Conversation) {
|
||||
// V1: Pause the conversation (agent execution)
|
||||
@@ -62,10 +49,6 @@ export function ChatInputActions({
|
||||
handleResumeAgent();
|
||||
};
|
||||
|
||||
const handleStartClick = () => {
|
||||
resumeConversationSandboxMutation.mutate({ conversationId, providers });
|
||||
};
|
||||
|
||||
const isPausing =
|
||||
pauseConversationSandboxMutation.isPending ||
|
||||
v1PauseConversationMutation.isPending;
|
||||
@@ -74,12 +57,6 @@ export function ChatInputActions({
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tools />
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
handleStop={handleStopClick}
|
||||
handleResumeAgent={handleStartClick}
|
||||
/>
|
||||
</div>
|
||||
<AgentStatus
|
||||
className="ml-2 md:ml-3"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { DragOver } from "../drag-over";
|
||||
import { UploadedFiles } from "../uploaded-files";
|
||||
import { ChatInputRow } from "./chat-input-row";
|
||||
import { ChatInputActions } from "./chat-input-actions";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ChatInputContainerProps {
|
||||
chatContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
@@ -11,7 +12,6 @@ interface ChatInputContainerProps {
|
||||
disabled: boolean;
|
||||
showButton: boolean;
|
||||
buttonClassName: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
chatInputRef: React.RefObject<HTMLDivElement | null>;
|
||||
handleFileIconClick: (isDisabled: boolean) => void;
|
||||
handleSubmit: () => void;
|
||||
@@ -32,7 +32,6 @@ export function ChatInputContainer({
|
||||
disabled,
|
||||
showButton,
|
||||
buttonClassName,
|
||||
conversationStatus,
|
||||
chatInputRef,
|
||||
handleFileIconClick,
|
||||
handleSubmit,
|
||||
@@ -46,10 +45,17 @@ export function ChatInputContainer({
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: ChatInputContainerProps) {
|
||||
const conversationMode = useConversationStore(
|
||||
(state) => state.conversationMode,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatContainerRef}
|
||||
className="bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full"
|
||||
className={cn(
|
||||
"bg-[#25272D] box-border content-stretch flex flex-col items-start justify-center p-4 pt-3 relative rounded-[15px] w-full",
|
||||
conversationMode === "plan" && "border border-[#597FF4]",
|
||||
)}
|
||||
onDragOver={(e) => onDragOver(e, disabled)}
|
||||
onDragLeave={(e) => onDragLeave(e, disabled)}
|
||||
onDrop={(e) => onDrop(e, disabled)}
|
||||
@@ -74,7 +80,6 @@ export function ChatInputContainer({
|
||||
/>
|
||||
|
||||
<ChatInputActions
|
||||
conversationStatus={conversationStatus}
|
||||
disabled={disabled}
|
||||
handleResumeAgent={handleResumeAgent}
|
||||
/>
|
||||
|
||||
@@ -137,7 +137,6 @@ export function CustomChatInput({
|
||||
disabled={isDisabled}
|
||||
showButton={showButton}
|
||||
buttonClassName={buttonClassName}
|
||||
conversationStatus={conversationStatus}
|
||||
chatInputRef={chatInputRef}
|
||||
handleFileIconClick={handleFileIconClick}
|
||||
handleSubmit={handleSubmit}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import PRIcon from "#/icons/u-pr.svg?react";
|
||||
import { cn, getCreatePRPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPrButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,6 +20,7 @@ export function GitControlBarPrButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackCreatePrButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function GitControlBarPrButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePrClick = () => {
|
||||
posthog.capture("create_pr_button_clicked");
|
||||
trackCreatePrButtonClick();
|
||||
onSuggestionsClick(getCreatePRPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowDownIcon from "#/icons/u-arrow-down.svg?react";
|
||||
import { cn, getGitPullPrompt } from "#/utils/utils";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPullButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -16,6 +16,7 @@ export function GitControlBarPullButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPullButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPullButtonClick } = useTracking();
|
||||
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { providers } = useUserProviders();
|
||||
@@ -26,7 +27,7 @@ export function GitControlBarPullButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePullClick = () => {
|
||||
posthog.capture("pull_button_clicked");
|
||||
trackPullButtonClick();
|
||||
onSuggestionsClick(getGitPullPrompt());
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import posthog from "posthog-js";
|
||||
import ArrowUpIcon from "#/icons/u-arrow-up.svg?react";
|
||||
import { cn, getGitPushPrompt } from "#/utils/utils";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface GitControlBarPushButtonProps {
|
||||
onSuggestionsClick: (value: string) => void;
|
||||
@@ -20,6 +20,7 @@ export function GitControlBarPushButton({
|
||||
isConversationReady = true,
|
||||
}: GitControlBarPushButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackPushButtonClick } = useTracking();
|
||||
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
@@ -28,7 +29,7 @@ export function GitControlBarPushButton({
|
||||
providersAreSet && hasRepository && isConversationReady;
|
||||
|
||||
const handlePushClick = () => {
|
||||
posthog.capture("push_button_clicked");
|
||||
trackPushButtonClick();
|
||||
onSuggestionsClick(getGitPushPrompt(currentGitProvider));
|
||||
};
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useConversationStore } from "#/state/conversation-store";
|
||||
import CircleErrorIcon from "#/icons/circle-error.svg?react";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useUnifiedWebSocketStatus } from "#/hooks/use-unified-websocket-status";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
|
||||
export interface AgentStatusProps {
|
||||
className?: string;
|
||||
@@ -35,6 +36,7 @@ export function AgentStatus({
|
||||
const { curStatusMessage } = useStatusStore();
|
||||
const webSocketStatus = useUnifiedWebSocketStatus();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { taskStatus } = useTaskPolling();
|
||||
|
||||
const statusCode = getStatusCode(
|
||||
curStatusMessage,
|
||||
@@ -42,25 +44,34 @@ export function AgentStatus({
|
||||
conversation?.status || null,
|
||||
conversation?.runtime_status || null,
|
||||
curAgentState,
|
||||
taskStatus,
|
||||
);
|
||||
|
||||
const isTaskLoading =
|
||||
taskStatus && taskStatus !== "ERROR" && taskStatus !== "READY";
|
||||
|
||||
const shouldShownAgentLoading =
|
||||
isPausing ||
|
||||
curAgentState === AgentState.INIT ||
|
||||
curAgentState === AgentState.LOADING ||
|
||||
webSocketStatus === "CONNECTING";
|
||||
(webSocketStatus === "CONNECTING" && taskStatus !== "ERROR") ||
|
||||
isTaskLoading;
|
||||
|
||||
const shouldShownAgentError =
|
||||
curAgentState === AgentState.ERROR ||
|
||||
curAgentState === AgentState.RATE_LIMITED;
|
||||
curAgentState === AgentState.RATE_LIMITED ||
|
||||
webSocketStatus === "DISCONNECTED" ||
|
||||
taskStatus === "ERROR";
|
||||
|
||||
const shouldShownAgentStop = curAgentState === AgentState.RUNNING;
|
||||
|
||||
const shouldShownAgentResume = curAgentState === AgentState.STOPPED;
|
||||
const shouldShownAgentResume =
|
||||
curAgentState === AgentState.STOPPED || curAgentState === AgentState.PAUSED;
|
||||
|
||||
// Update global state when agent loading condition changes
|
||||
useEffect(() => {
|
||||
setShouldShownAgentLoading(shouldShownAgentLoading);
|
||||
if (shouldShownAgentLoading)
|
||||
setShouldShownAgentLoading(shouldShownAgentLoading);
|
||||
}, [shouldShownAgentLoading, setShouldShownAgentLoading]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,7 +13,7 @@ export function ServerStatusContextMenuIconText({
|
||||
}: ServerStatusContextMenuIconTextProps) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
className="flex items-center justify-between p-2 hover:bg-[#5C5D62] rounded text-sm text-white font-normal leading-5 cursor-pointer w-full"
|
||||
onClick={onClick}
|
||||
data-testid={testId}
|
||||
type="button"
|
||||
|
||||
@@ -6,6 +6,9 @@ import { ConversationStatus } from "#/types/conversation-status";
|
||||
import StopCircleIcon from "#/icons/stop-circle.svg?react";
|
||||
import PlayCircleIcon from "#/icons/play-circle.svg?react";
|
||||
import { ServerStatusContextMenuIconText } from "./server-status-context-menu-icon-text";
|
||||
import { ServerStatus } from "./server-status";
|
||||
import { Divider } from "#/ui/divider";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ServerStatusContextMenuProps {
|
||||
onClose: () => void;
|
||||
@@ -13,6 +16,8 @@ interface ServerStatusContextMenuProps {
|
||||
onStartServer?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
position?: "top" | "bottom";
|
||||
className?: string;
|
||||
isPausing?: boolean;
|
||||
}
|
||||
|
||||
export function ServerStatusContextMenu({
|
||||
@@ -21,10 +26,15 @@ export function ServerStatusContextMenu({
|
||||
onStartServer,
|
||||
conversationStatus,
|
||||
position = "top",
|
||||
className = "",
|
||||
isPausing = false,
|
||||
}: ServerStatusContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
|
||||
const shouldActionShown =
|
||||
conversationStatus === "RUNNING" || conversationStatus === "STOPPED";
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
ref={ref}
|
||||
@@ -32,24 +42,36 @@ export function ServerStatusContextMenu({
|
||||
position={position}
|
||||
alignment="left"
|
||||
size="default"
|
||||
className="left-2 w-fit min-w-max"
|
||||
className={cn("left-2 w-fit min-w-42", className)}
|
||||
>
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
<ServerStatus
|
||||
conversationStatus={conversationStatus}
|
||||
isPausing={isPausing}
|
||||
className="py-1"
|
||||
/>
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
{shouldActionShown && (
|
||||
<>
|
||||
<Divider />
|
||||
|
||||
{conversationStatus === "RUNNING" && onStopServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<StopCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$STOP_RUNTIME)}
|
||||
onClick={onStopServer}
|
||||
testId="stop-server-button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{conversationStatus === "STOPPED" && onStartServer && (
|
||||
<ServerStatusContextMenuIconText
|
||||
icon={<PlayCircleIcon width={18} height={18} />}
|
||||
text={t(I18nKey.COMMON$START_RUNTIME)}
|
||||
onClick={onStartServer}
|
||||
testId="start-server-button"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ServerStatusContextMenu } from "./server-status-context-menu";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
|
||||
export interface ServerStatusProps {
|
||||
className?: string;
|
||||
conversationStatus: ConversationStatus | null;
|
||||
isPausing?: boolean;
|
||||
handleStop: () => void;
|
||||
handleResumeAgent: () => void;
|
||||
}
|
||||
|
||||
export function ServerStatus({
|
||||
className = "",
|
||||
conversationStatus,
|
||||
isPausing = false,
|
||||
handleStop,
|
||||
handleResumeAgent,
|
||||
}: ServerStatusProps) {
|
||||
const [showContextMenu, setShowContextMenu] = useState(false);
|
||||
|
||||
const { curAgentState } = useAgentState();
|
||||
const { t } = useTranslation();
|
||||
const { isTask, taskStatus, taskDetail } = useTaskPolling();
|
||||
@@ -34,34 +27,15 @@ export function ServerStatus({
|
||||
|
||||
const isStopStatus = conversationStatus === "STOPPED";
|
||||
|
||||
// Get the appropriate color based on agent status
|
||||
const getStatusColor = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
return "#FFD600";
|
||||
}
|
||||
const statusColor = getStatusColor({
|
||||
isPausing,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
// Show task status if we're polling a task
|
||||
if (isTask && taskStatus) {
|
||||
if (taskStatus === "ERROR") {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#FFD600";
|
||||
}
|
||||
|
||||
if (isStartingStatus) {
|
||||
return "#FFD600";
|
||||
}
|
||||
if (isStopStatus) {
|
||||
return "#ffffff";
|
||||
}
|
||||
if (curAgentState === AgentState.ERROR) {
|
||||
return "#FF684E";
|
||||
}
|
||||
return "#BCFF8C";
|
||||
};
|
||||
|
||||
// Get the appropriate status text based on agent status
|
||||
const getStatusText = (): string => {
|
||||
// Show pausing status
|
||||
if (isPausing) {
|
||||
@@ -100,49 +74,14 @@ export function ServerStatus({
|
||||
return t(I18nKey.COMMON$RUNNING);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (conversationStatus === "RUNNING" || conversationStatus === "STOPPED") {
|
||||
setShowContextMenu(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseContextMenu = () => {
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleStop();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
handleResumeAgent();
|
||||
setShowContextMenu(false);
|
||||
};
|
||||
|
||||
const statusColor = getStatusColor();
|
||||
const statusText = getStatusText();
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<div className="flex items-center cursor-pointer" onClick={handleClick}>
|
||||
<div className={className} data-testid="server-status">
|
||||
<div className="flex items-center">
|
||||
<DebugStackframeDot className="w-6 h-6" color={statusColor} />
|
||||
<span className="text-[11px] text-white font-normal leading-5">
|
||||
{statusText}
|
||||
</span>
|
||||
<span className="text-[13px] text-white font-normal">{statusText}</span>
|
||||
</div>
|
||||
|
||||
{showContextMenu && (
|
||||
<ServerStatusContextMenu
|
||||
onClose={handleCloseContextMenu}
|
||||
onStopServer={handleStopServer}
|
||||
onStartServer={handleStartServer}
|
||||
conversationStatus={conversationStatus}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
import { useParams } from "react-router";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useTaskPolling } from "#/hooks/query/use-task-polling";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useUnifiedPauseConversationSandbox } from "#/hooks/mutation/use-unified-stop-conversation";
|
||||
import { useUnifiedResumeConversationSandbox } from "#/hooks/mutation/use-unified-start-conversation";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { getStatusColor } from "#/utils/utils";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import DebugStackframeDot from "#/icons/debug-stackframe-dot.svg?react";
|
||||
import { ServerStatusContextMenu } from "../controls/server-status-context-menu";
|
||||
import { ConversationName } from "./conversation-name";
|
||||
|
||||
export function ConversationNameWithStatus() {
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { isTask, taskStatus } = useTaskPolling();
|
||||
const { mutate: pauseConversationSandbox } =
|
||||
useUnifiedPauseConversationSandbox();
|
||||
const { mutate: resumeConversationSandbox } =
|
||||
useUnifiedResumeConversationSandbox();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
const isStartingStatus =
|
||||
curAgentState === AgentState.LOADING || curAgentState === AgentState.INIT;
|
||||
const isStopStatus = conversation?.status === "STOPPED";
|
||||
|
||||
const statusColor = getStatusColor({
|
||||
isPausing: false,
|
||||
isTask,
|
||||
taskStatus,
|
||||
isStartingStatus,
|
||||
isStopStatus,
|
||||
curAgentState,
|
||||
});
|
||||
|
||||
const handleStopServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
pauseConversationSandbox({ conversationId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartServer = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (conversationId) {
|
||||
resumeConversationSandbox({ conversationId, providers });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="group relative">
|
||||
<DebugStackframeDot
|
||||
className="ml-[3.5px] w-6 h-6 cursor-pointer"
|
||||
color={statusColor}
|
||||
/>
|
||||
<ServerStatusContextMenu
|
||||
onClose={() => {}}
|
||||
onStopServer={
|
||||
conversation?.status === "RUNNING" ? handleStopServer : undefined
|
||||
}
|
||||
onStartServer={
|
||||
conversation?.status === "STOPPED" ? handleStartServer : undefined
|
||||
}
|
||||
conversationStatus={conversation?.status ?? null}
|
||||
position="bottom"
|
||||
className="opacity-0 invisible pointer-events-none group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto bottom-full left-0 mt-0 min-h-fit"
|
||||
isPausing={false}
|
||||
/>
|
||||
</div>
|
||||
<ConversationName />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,7 +124,7 @@ export function ConversationName() {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-3.5"
|
||||
className="flex items-center gap-2 h-[22px] text-base font-normal text-left pl-0 lg:pl-1"
|
||||
data-testid="conversation-name"
|
||||
>
|
||||
{titleMode === "edit" ? (
|
||||
|
||||
+18
-12
@@ -8,26 +8,28 @@ import { TabContentArea } from "./tab-content-area";
|
||||
import { ConversationTabTitle } from "../conversation-tab-title";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
const BrowserTab = lazy(() => import("#/routes/browser-tab"));
|
||||
const JupyterTab = lazy(() => import("#/routes/jupyter-tab"));
|
||||
const ServedTab = lazy(() => import("#/routes/served-tab"));
|
||||
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Determine which tab is active based on the current path
|
||||
const isEditorActive = selectedTab === "editor";
|
||||
const isBrowserActive = selectedTab === "browser";
|
||||
const isJupyterActive = selectedTab === "jupyter";
|
||||
const isServedActive = selectedTab === "served";
|
||||
const isVSCodeActive = selectedTab === "vscode";
|
||||
const isTerminalActive = selectedTab === "terminal";
|
||||
const isPlannerActive = selectedTab === "planner";
|
||||
|
||||
// Define tab configurations
|
||||
const tabs = [
|
||||
@@ -37,11 +39,6 @@ export function ConversationTabContent() {
|
||||
component: BrowserTab,
|
||||
isActive: isBrowserActive,
|
||||
},
|
||||
{
|
||||
key: "jupyter",
|
||||
component: JupyterTab,
|
||||
isActive: isJupyterActive,
|
||||
},
|
||||
{ key: "served", component: ServedTab, isActive: isServedActive },
|
||||
{ key: "vscode", component: VSCodeTab, isActive: isVSCodeActive },
|
||||
{
|
||||
@@ -49,6 +46,11 @@ export function ConversationTabContent() {
|
||||
component: Terminal,
|
||||
isActive: isTerminalActive,
|
||||
},
|
||||
{
|
||||
key: "planner",
|
||||
component: PlannerTab,
|
||||
isActive: isPlannerActive,
|
||||
},
|
||||
];
|
||||
|
||||
const conversationTabTitle = useMemo(() => {
|
||||
@@ -58,9 +60,6 @@ export function ConversationTabContent() {
|
||||
if (isBrowserActive) {
|
||||
return t(I18nKey.COMMON$BROWSER);
|
||||
}
|
||||
if (isJupyterActive) {
|
||||
return t(I18nKey.COMMON$JUPYTER);
|
||||
}
|
||||
if (isServedActive) {
|
||||
return t(I18nKey.COMMON$APP);
|
||||
}
|
||||
@@ -70,14 +69,17 @@ export function ConversationTabContent() {
|
||||
if (isTerminalActive) {
|
||||
return t(I18nKey.COMMON$TERMINAL);
|
||||
}
|
||||
if (isPlannerActive) {
|
||||
return t(I18nKey.COMMON$PLANNER);
|
||||
}
|
||||
return "";
|
||||
}, [
|
||||
isEditorActive,
|
||||
isBrowserActive,
|
||||
isJupyterActive,
|
||||
isServedActive,
|
||||
isVSCodeActive,
|
||||
isTerminalActive,
|
||||
isPlannerActive,
|
||||
]);
|
||||
|
||||
if (shouldShownAgentLoading) {
|
||||
@@ -89,7 +91,11 @@ export function ConversationTabContent() {
|
||||
<ConversationTabTitle title={conversationTabTitle} />
|
||||
<TabContentArea>
|
||||
{tabs.map(({ key, component: Component, isActive }) => (
|
||||
<TabWrapper key={key} isActive={isActive}>
|
||||
<TabWrapper
|
||||
// Force Terminal tab remount to reset XTerm buffer/state when conversationId changes
|
||||
key={key === "terminal" ? `${key}-${conversationId}` : key}
|
||||
isActive={isActive}
|
||||
>
|
||||
<Component />
|
||||
</TabWrapper>
|
||||
))}
|
||||
|
||||
+12
-5
@@ -5,12 +5,16 @@ type ConversationTabNavProps = {
|
||||
icon: ComponentType<{ className: string }>;
|
||||
onClick(): void;
|
||||
isActive?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ConversationTabNav({
|
||||
icon: Icon,
|
||||
onClick,
|
||||
isActive,
|
||||
label,
|
||||
className,
|
||||
}: ConversationTabNavProps) {
|
||||
return (
|
||||
<button
|
||||
@@ -19,18 +23,21 @@ export function ConversationTabNav({
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded-md cursor-pointer",
|
||||
"flex items-center gap-2 rounded-md cursor-pointer",
|
||||
"pl-1.5 pr-2 py-1",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
isActive && "bg-[#25272D] text-white",
|
||||
isActive
|
||||
? "hover:text-white hover:bg-tertiary"
|
||||
: "hover:text-white hover:bg-[#0D0F11]",
|
||||
isActive
|
||||
? "focus-within:text-white focus-within:bg-tertiary"
|
||||
: "focus-within:text-[#9299AA] focus-within:bg-[#0D0F11]",
|
||||
isActive ? "focus-within:text-white" : "focus-within:text-[#9299AA]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-5 h-5 text-inherit")} />
|
||||
<Icon className={cn("w-5 h-5 text-inherit flex-shrink-0")} />
|
||||
{isActive && label && (
|
||||
<span className="text-sm font-medium whitespace-nowrap">{label}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import { ContextMenu } from "#/ui/context-menu";
|
||||
import { ContextMenuListItem } from "../../context-menu/context-menu-list-item";
|
||||
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import PillIcon from "#/icons/pill.svg?react";
|
||||
import PillFillIcon from "#/icons/pill-fill.svg?react";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
|
||||
interface ConversationTabsContextMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConversationTabsContextMenu({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ConversationTabsContextMenuProps) {
|
||||
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
|
||||
const { t } = useTranslation();
|
||||
const [unpinnedTabs, setUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
[],
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const tabConfig = [
|
||||
{
|
||||
tab: "editor",
|
||||
icon: GitChanges,
|
||||
i18nKey: I18nKey.COMMON$CHANGES,
|
||||
},
|
||||
{
|
||||
tab: "vscode",
|
||||
icon: VSCodeIcon,
|
||||
i18nKey: I18nKey.COMMON$CODE,
|
||||
},
|
||||
{
|
||||
tab: "terminal",
|
||||
icon: TerminalIcon,
|
||||
i18nKey: I18nKey.COMMON$TERMINAL,
|
||||
},
|
||||
{
|
||||
tab: "served",
|
||||
icon: ServerIcon,
|
||||
i18nKey: I18nKey.COMMON$APP,
|
||||
},
|
||||
{
|
||||
tab: "browser",
|
||||
icon: GlobeIcon,
|
||||
i18nKey: I18nKey.COMMON$BROWSER,
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldUsePlanningAgent) {
|
||||
tabConfig.unshift({
|
||||
tab: "planner",
|
||||
icon: LessonPlanIcon,
|
||||
i18nKey: I18nKey.COMMON$PLANNER,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTabClick = (tab: string) => {
|
||||
const tabString = tab;
|
||||
if (unpinnedTabs.includes(tabString)) {
|
||||
// Tab is unpinned, pin it (remove from unpinned list)
|
||||
setUnpinnedTabs(
|
||||
unpinnedTabs.filter((unpinnedTab) => unpinnedTab !== tabString),
|
||||
);
|
||||
} else {
|
||||
// Tab is pinned, unpin it (add to unpinned list)
|
||||
setUnpinnedTabs([...unpinnedTabs, tabString]);
|
||||
}
|
||||
};
|
||||
|
||||
const isTabPinned = (tab: string) => !unpinnedTabs.includes(tab as string);
|
||||
|
||||
return (
|
||||
<ContextMenu
|
||||
testId="conversation-tabs-context-menu"
|
||||
ref={ref}
|
||||
alignment="right"
|
||||
position="bottom"
|
||||
className="mt-2 w-fit z-[9999]"
|
||||
>
|
||||
{tabConfig.map(({ tab, icon: Icon, i18nKey }) => {
|
||||
const pinned = isTabPinned(tab);
|
||||
return (
|
||||
<ContextMenuListItem
|
||||
key={tab}
|
||||
onClick={() => handleTabClick(tab)}
|
||||
className="flex items-center gap-2 p-2 hover:bg-[#5C5D62] rounded h-[30px]"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span className="text-white text-sm">{t(i18nKey)}</span>
|
||||
{pinned ? (
|
||||
<PillFillIcon className="w-7 h-7 ml-auto flex-shrink-0 text-white -mr-[5px]" />
|
||||
) : (
|
||||
<PillIcon className="w-4.5 h-4.5 ml-auto flex-shrink-0 text-white" />
|
||||
)}
|
||||
</ContextMenuListItem>
|
||||
);
|
||||
})}
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
+71
-11
@@ -1,12 +1,13 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocalStorage } from "@uidotdev/usehooks";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import ServerIcon from "#/icons/server.svg?react";
|
||||
import GitChanges from "#/icons/git_changes.svg?react";
|
||||
import VSCodeIcon from "#/icons/vscode.svg?react";
|
||||
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
|
||||
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ConversationTabNav } from "./conversation-tab-nav";
|
||||
import { ChatActionTooltip } from "../../chat/chat-action-tooltip";
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
useConversationStore,
|
||||
type ConversationTab,
|
||||
} from "#/state/conversation-store";
|
||||
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
|
||||
import { USE_PLANNING_AGENT } from "#/utils/feature-flags";
|
||||
|
||||
export function ConversationTabs() {
|
||||
const {
|
||||
@@ -25,6 +28,8 @@ export function ConversationTabs() {
|
||||
setSelectedTab,
|
||||
} = useConversationStore();
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
// Persist selectedTab and isRightPanelShown in localStorage
|
||||
const [persistedSelectedTab, setPersistedSelectedTab] =
|
||||
useLocalStorage<ConversationTab | null>(
|
||||
@@ -35,6 +40,13 @@ export function ConversationTabs() {
|
||||
const [persistedIsRightPanelShown, setPersistedIsRightPanelShown] =
|
||||
useLocalStorage<boolean>("conversation-right-panel-shown", true);
|
||||
|
||||
const [persistedUnpinnedTabs] = useLocalStorage<string[]>(
|
||||
"conversation-unpinned-tabs",
|
||||
[],
|
||||
);
|
||||
|
||||
const shouldUsePlanningAgent = USE_PLANNING_AGENT();
|
||||
|
||||
const onTabChange = (value: ConversationTab | null) => {
|
||||
setSelectedTab(value);
|
||||
// Persist the selected tab to localStorage
|
||||
@@ -88,49 +100,70 @@ export function ConversationTabs() {
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
tabValue: "editor",
|
||||
isActive: isTabActive("editor"),
|
||||
icon: GitChanges,
|
||||
onClick: () => onTabSelected("editor"),
|
||||
tooltipContent: t(I18nKey.COMMON$CHANGES),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$CHANGES),
|
||||
label: t(I18nKey.COMMON$CHANGES),
|
||||
},
|
||||
{
|
||||
tabValue: "vscode",
|
||||
isActive: isTabActive("vscode"),
|
||||
icon: VSCodeIcon,
|
||||
onClick: () => onTabSelected("vscode"),
|
||||
tooltipContent: <VSCodeTooltipContent />,
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$CODE),
|
||||
label: t(I18nKey.COMMON$CODE),
|
||||
},
|
||||
{
|
||||
tabValue: "terminal",
|
||||
isActive: isTabActive("terminal"),
|
||||
icon: TerminalIcon,
|
||||
onClick: () => onTabSelected("terminal"),
|
||||
tooltipContent: t(I18nKey.COMMON$TERMINAL),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$TERMINAL),
|
||||
label: t(I18nKey.COMMON$TERMINAL),
|
||||
className: "pl-2",
|
||||
},
|
||||
{
|
||||
isActive: isTabActive("jupyter"),
|
||||
icon: JupyterIcon,
|
||||
onClick: () => onTabSelected("jupyter"),
|
||||
tooltipContent: t(I18nKey.COMMON$JUPYTER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$JUPYTER),
|
||||
},
|
||||
{
|
||||
tabValue: "served",
|
||||
isActive: isTabActive("served"),
|
||||
icon: ServerIcon,
|
||||
onClick: () => onTabSelected("served"),
|
||||
tooltipContent: t(I18nKey.COMMON$APP),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$APP),
|
||||
label: t(I18nKey.COMMON$APP),
|
||||
},
|
||||
{
|
||||
tabValue: "browser",
|
||||
isActive: isTabActive("browser"),
|
||||
icon: GlobeIcon,
|
||||
onClick: () => onTabSelected("browser"),
|
||||
tooltipContent: t(I18nKey.COMMON$BROWSER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$BROWSER),
|
||||
label: t(I18nKey.COMMON$BROWSER),
|
||||
},
|
||||
];
|
||||
|
||||
if (shouldUsePlanningAgent) {
|
||||
tabs.unshift({
|
||||
tabValue: "planner",
|
||||
isActive: isTabActive("planner"),
|
||||
icon: LessonPlanIcon,
|
||||
onClick: () => onTabSelected("planner"),
|
||||
tooltipContent: t(I18nKey.COMMON$PLANNER),
|
||||
tooltipAriaLabel: t(I18nKey.COMMON$PLANNER),
|
||||
label: t(I18nKey.COMMON$PLANNER),
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out unpinned tabs
|
||||
const visibleTabs = tabs.filter(
|
||||
(tab) => !persistedUnpinnedTabs.includes(tab.tabValue),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -138,9 +171,17 @@ export function ConversationTabs() {
|
||||
"flex flex-row justify-start lg:justify-end items-center gap-4.5",
|
||||
)}
|
||||
>
|
||||
{tabs.map(
|
||||
{visibleTabs.map(
|
||||
(
|
||||
{ icon, onClick, isActive, tooltipContent, tooltipAriaLabel },
|
||||
{
|
||||
icon,
|
||||
onClick,
|
||||
isActive,
|
||||
tooltipContent,
|
||||
tooltipAriaLabel,
|
||||
label,
|
||||
className,
|
||||
},
|
||||
index,
|
||||
) => (
|
||||
<ChatActionTooltip
|
||||
@@ -152,10 +193,29 @@ export function ConversationTabs() {
|
||||
icon={icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
label={label}
|
||||
className={className}
|
||||
/>
|
||||
</ChatActionTooltip>
|
||||
),
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||
className={cn(
|
||||
"p-1 pl-0 rounded-md cursor-pointer",
|
||||
"text-[#9299AA] bg-[#0D0F11]",
|
||||
)}
|
||||
aria-label={t(I18nKey.COMMON$MORE_OPTIONS)}
|
||||
>
|
||||
<ThreeDotsVerticalIcon className={cn("w-5 h-5 text-inherit")} />
|
||||
</button>
|
||||
<ConversationTabsContextMenu
|
||||
isOpen={isMenuOpen}
|
||||
onClose={() => setIsMenuOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { GitChangeStatus } from "#/api/open-hands.types";
|
||||
import { getLanguageFromPath } from "#/utils/get-language-from-path";
|
||||
import { cn } from "#/utils/utils";
|
||||
import ChevronUp from "#/icons/chveron-up.svg?react";
|
||||
import { useGitDiff } from "#/hooks/query/use-get-diff";
|
||||
import { useUnifiedGitDiff } from "#/hooks/query/use-unified-git-diff";
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
className?: string;
|
||||
@@ -64,7 +64,7 @@ export function FileDiffViewer({ path, type }: FileDiffViewerProps) {
|
||||
isLoading,
|
||||
isSuccess,
|
||||
isRefetching,
|
||||
} = useGitDiff({
|
||||
} = useUnifiedGitDiff({
|
||||
filePath,
|
||||
type,
|
||||
enabled: !isCollapsed,
|
||||
|
||||
@@ -131,7 +131,7 @@ export function RepositorySelectionForm({
|
||||
onBranchSelect={handleBranchSelection}
|
||||
defaultBranch={defaultBranch}
|
||||
placeholder="Select branch..."
|
||||
className="max-w-[500px]"
|
||||
className="max-w-full"
|
||||
disabled={!selectedRepository || isLoadingSettings}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
|
||||
interface JupytrerCellInputProps {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export function JupytrerCellInput({ code }: JupytrerCellInputProps) {
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">EXECUTE</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
<SyntaxHighlighter language="python" style={atomOneDark} wrapLongLines>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import Markdown from "react-markdown";
|
||||
import SyntaxHighlighter from "react-syntax-highlighter";
|
||||
import { atomOneDark } from "react-syntax-highlighter/dist/esm/styles/hljs";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { JupyterLine } from "#/utils/parse-cell-content";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
|
||||
interface JupyterCellOutputProps {
|
||||
lines: JupyterLine[];
|
||||
}
|
||||
|
||||
export function JupyterCellOutput({ lines }: JupyterCellOutputProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-lg bg-gray-800 dark:bg-gray-900 p-2 text-xs">
|
||||
<div className="mb-1 text-gray-400">
|
||||
{t(I18nKey.JUPYTER$OUTPUT_LABEL)}
|
||||
</div>
|
||||
<pre
|
||||
className="scrollbar-custom scrollbar-thumb-gray-500 hover:scrollbar-thumb-gray-400 dark:scrollbar-thumb-white/10 dark:hover:scrollbar-thumb-white/20 overflow-auto px-5 max-h-[60vh] bg-gray-800"
|
||||
style={{ padding: 0, marginBottom: 0, fontSize: "0.75rem" }}
|
||||
>
|
||||
{/* display the lines as plaintext or image */}
|
||||
{lines.map((line, index) => {
|
||||
if (line.type === "image") {
|
||||
// Use markdown to display the image
|
||||
const imageMarkdown = line.url
|
||||
? ``
|
||||
: line.content;
|
||||
return (
|
||||
<div key={index}>
|
||||
<Markdown
|
||||
components={{
|
||||
p: paragraph,
|
||||
}}
|
||||
urlTransform={(value: string) => value}
|
||||
>
|
||||
{imageMarkdown}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={index}>
|
||||
<SyntaxHighlighter language="plaintext" style={atomOneDark}>
|
||||
{line.content}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from "react";
|
||||
import { Cell } from "#/state/jupyter-store";
|
||||
import { JupyterLine, parseCellContent } from "#/utils/parse-cell-content";
|
||||
import { JupytrerCellInput } from "./jupyter-cell-input";
|
||||
import { JupyterCellOutput } from "./jupyter-cell-output";
|
||||
|
||||
interface JupyterCellProps {
|
||||
cell: Cell;
|
||||
}
|
||||
|
||||
export function JupyterCell({ cell }: JupyterCellProps) {
|
||||
const [lines, setLines] = React.useState<JupyterLine[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLines(parseCellContent(cell.content, cell.imageUrls));
|
||||
}, [cell.content, cell.imageUrls]);
|
||||
|
||||
if (cell.type === "input") {
|
||||
return <JupytrerCellInput code={cell.content} />;
|
||||
}
|
||||
|
||||
return <JupyterCellOutput lines={lines} />;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useScrollToBottom } from "#/hooks/use-scroll-to-bottom";
|
||||
import { JupyterCell } from "./jupyter-cell";
|
||||
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import JupyterLargeIcon from "#/icons/jupyter-large.svg?react";
|
||||
import { WaitingForRuntimeMessage } from "../chat/waiting-for-runtime-message";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useJupyterStore } from "#/state/jupyter-store";
|
||||
|
||||
interface JupyterEditorProps {
|
||||
maxWidth: number;
|
||||
}
|
||||
|
||||
export function JupyterEditor({ maxWidth }: JupyterEditorProps) {
|
||||
const { curAgentState } = useAgentState();
|
||||
|
||||
const cells = useJupyterStore((state) => state.cells);
|
||||
|
||||
const jupyterRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
const { hitBottom, scrollDomToBottom, onChatBodyScroll } =
|
||||
useScrollToBottom(jupyterRef);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRuntimeInactive && <WaitingForRuntimeMessage />}
|
||||
{!isRuntimeInactive && cells.length > 0 && (
|
||||
<div className="flex-1 h-full flex flex-col" style={{ maxWidth }}>
|
||||
<div
|
||||
data-testid="jupyter-container"
|
||||
className="flex-1 overflow-y-auto fast-smooth-scroll custom-scrollbar-always rounded-xl"
|
||||
ref={jupyterRef}
|
||||
onScroll={(e) => onChatBodyScroll(e.currentTarget)}
|
||||
>
|
||||
{cells.map((cell, index) => (
|
||||
<JupyterCell key={index} cell={cell} />
|
||||
))}
|
||||
</div>
|
||||
{!hitBottom && (
|
||||
<div className="sticky bottom-2 flex items-center justify-center">
|
||||
<ScrollToBottomButton onClick={scrollDomToBottom} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isRuntimeInactive && cells.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
|
||||
<JupyterLargeIcon width={113} height={113} color="#A1A1A1" />
|
||||
<span className="text-[#8D95A9] text-[19px] font-normal leading-5">
|
||||
{t(I18nKey.COMMON$JUPYTER_EMPTY_MESSAGE)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,5 +7,5 @@ export function paragraph({
|
||||
}: React.ClassAttributes<HTMLParagraphElement> &
|
||||
React.HTMLAttributes<HTMLParagraphElement> &
|
||||
ExtraProps) {
|
||||
return <p className="pb-[10px] last:pb-0">{children}</p>;
|
||||
return <p className="py-2.5 first:pt-0 last:pb-0">{children}</p>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useCreateStripeCheckoutSession } from "#/hooks/mutation/stripe/use-create-stripe-checkout-session";
|
||||
import { useBalance } from "#/hooks/query/use-balance";
|
||||
import { useSubscriptionAccess } from "#/hooks/query/use-subscription-access";
|
||||
import { cn } from "#/utils/utils";
|
||||
import MoneyIcon from "#/icons/money.svg?react";
|
||||
import { SettingsInput } from "../settings/settings-input";
|
||||
@@ -11,24 +10,13 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner";
|
||||
import { amountIsValid } from "#/utils/amount-is-valid";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { PoweredByStripeTag } from "./powered-by-stripe-tag";
|
||||
import { CancelSubscriptionModal } from "./cancel-subscription-modal";
|
||||
|
||||
export function PaymentForm() {
|
||||
const { t } = useTranslation();
|
||||
const { data: balance, isLoading } = useBalance();
|
||||
const { data: subscriptionAccess } = useSubscriptionAccess();
|
||||
const { mutate: addBalance, isPending } = useCreateStripeCheckoutSession();
|
||||
|
||||
const [buttonIsDisabled, setButtonIsDisabled] = React.useState(true);
|
||||
const [showCancelModal, setShowCancelModal] = React.useState(false);
|
||||
|
||||
const subscriptionExpiredDate =
|
||||
subscriptionAccess?.end_at &&
|
||||
new Date(subscriptionAccess.end_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const billingFormAction = async (formData: FormData) => {
|
||||
const amount = formData.get("top-up-input")?.toString();
|
||||
@@ -94,50 +82,7 @@ export function PaymentForm() {
|
||||
{isPending && <LoadingSpinner size="small" />}
|
||||
<PoweredByStripeTag />
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Button or Cancellation Message */}
|
||||
{subscriptionAccess && (
|
||||
<div className="flex flex-col w-[680px] gap-2 mt-4">
|
||||
{subscriptionAccess.cancelled_at ? (
|
||||
<div className="text-red-500 text-sm">
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$SUBSCRIPTION_CANCELLED_EXPIRES}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-4">
|
||||
<BrandButton
|
||||
testId="cancel-subscription-button"
|
||||
variant="ghost-danger"
|
||||
type="button"
|
||||
onClick={() => setShowCancelModal(true)}
|
||||
>
|
||||
{t(I18nKey.PAYMENT$CANCEL_SUBSCRIPTION)}
|
||||
</BrandButton>
|
||||
<div
|
||||
className="text-sm text-gray-300"
|
||||
data-testid="next-billing-date"
|
||||
>
|
||||
<Trans
|
||||
i18nKey={I18nKey.PAYMENT$NEXT_BILLING_DATE}
|
||||
values={{ date: subscriptionExpiredDate }}
|
||||
components={{ date: <span className="underline" /> }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cancel Subscription Modal */}
|
||||
<CancelSubscriptionModal
|
||||
isOpen={showCancelModal}
|
||||
onClose={() => setShowCancelModal(false)}
|
||||
endDate={subscriptionExpiredDate}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,11 @@ interface NavigationItem {
|
||||
interface SettingsLayoutProps {
|
||||
children: React.ReactNode;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsLayout({
|
||||
children,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsLayoutProps) {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
@@ -44,7 +42,6 @@ export function SettingsLayout({
|
||||
isMobileMenuOpen={isMobileMenuOpen}
|
||||
onCloseMobileMenu={closeMobileMenu}
|
||||
navigationItems={navigationItems}
|
||||
isSaas={isSaas}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import SettingsIcon from "#/icons/settings-gear.svg?react";
|
||||
import CloseIcon from "#/icons/close.svg?react";
|
||||
import { ProPill } from "./pro-pill";
|
||||
|
||||
interface NavigationItem {
|
||||
to: string;
|
||||
@@ -17,14 +16,12 @@ interface SettingsNavigationProps {
|
||||
isMobileMenuOpen: boolean;
|
||||
onCloseMobileMenu: () => void;
|
||||
navigationItems: NavigationItem[];
|
||||
isSaas: boolean;
|
||||
}
|
||||
|
||||
export function SettingsNavigation({
|
||||
isMobileMenuOpen,
|
||||
onCloseMobileMenu,
|
||||
navigationItems,
|
||||
isSaas,
|
||||
}: SettingsNavigationProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -85,7 +82,6 @@ export function SettingsNavigation({
|
||||
<Typography.Text className="text-[#A3A3A3] whitespace-nowrap">
|
||||
{t(text as I18nKey)}
|
||||
</Typography.Text>
|
||||
{isSaas && to === "/settings" && <ProPill />}
|
||||
</div>
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,7 @@ import BitbucketLogo from "#/assets/branding/bitbucket-logo.svg?react";
|
||||
import { useAuthUrl } from "#/hooks/use-auth-url";
|
||||
import { GetConfigResponse } from "#/api/option-service/option.types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface AuthModalProps {
|
||||
githubAuthUrl: string | null;
|
||||
@@ -26,6 +27,7 @@ export function AuthModal({
|
||||
providersConfigured,
|
||||
}: AuthModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { trackLoginButtonClick } = useTracking();
|
||||
|
||||
const gitlabAuthUrl = useAuthUrl({
|
||||
appMode: appMode || null,
|
||||
@@ -47,6 +49,7 @@ export function AuthModal({
|
||||
|
||||
const handleGitHubAuth = () => {
|
||||
if (githubAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "github" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = githubAuthUrl;
|
||||
}
|
||||
@@ -54,6 +57,7 @@ export function AuthModal({
|
||||
|
||||
const handleGitLabAuth = () => {
|
||||
if (gitlabAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "gitlab" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = gitlabAuthUrl;
|
||||
}
|
||||
@@ -61,6 +65,7 @@ export function AuthModal({
|
||||
|
||||
const handleBitbucketAuth = () => {
|
||||
if (bitbucketAuthUrl) {
|
||||
trackLoginButtonClick({ provider: "bitbucket" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = bitbucketAuthUrl;
|
||||
}
|
||||
@@ -68,6 +73,7 @@ export function AuthModal({
|
||||
|
||||
const handleEnterpriseSsoAuth = () => {
|
||||
if (enterpriseSsoUrl) {
|
||||
trackLoginButtonClick({ provider: "enterprise_sso" });
|
||||
// Always start the OIDC flow, let the backend handle TOS check
|
||||
window.location.href = enterpriseSsoUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { ActionTooltip } from "../action-tooltip";
|
||||
import { RiskAlert } from "#/components/shared/risk-alert";
|
||||
import WarningIcon from "#/icons/u-warning.svg?react";
|
||||
import { useEventMessageStore } from "#/stores/event-message-store";
|
||||
import { useEventStore } from "#/stores/use-event-store";
|
||||
import { isV1Event, isActionEvent } from "#/types/v1/type-guards";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useAgentState } from "#/hooks/use-agent-state";
|
||||
import { useRespondToConfirmation } from "#/hooks/mutation/use-respond-to-confirmation";
|
||||
import { SecurityRisk } from "#/types/v1/core/base/common";
|
||||
|
||||
export function V1ConfirmationButtons() {
|
||||
const v1SubmittedEventIds = useEventMessageStore(
|
||||
(state) => state.v1SubmittedEventIds,
|
||||
);
|
||||
const addV1SubmittedEventId = useEventMessageStore(
|
||||
(state) => state.addV1SubmittedEventId,
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { curAgentState } = useAgentState();
|
||||
const { mutate: respondToConfirmation } = useRespondToConfirmation();
|
||||
const events = useEventStore((state) => state.events);
|
||||
|
||||
// Find the most recent V1 action awaiting confirmation
|
||||
const awaitingAction = events
|
||||
.filter(isV1Event)
|
||||
.slice()
|
||||
.reverse()
|
||||
.find((ev) => {
|
||||
if (ev.source !== "agent") return false;
|
||||
// For V1, we check if the agent state is waiting for confirmation
|
||||
return curAgentState === AgentState.AWAITING_USER_CONFIRMATION;
|
||||
});
|
||||
|
||||
const handleConfirmation = useCallback(
|
||||
(accept: boolean) => {
|
||||
if (!awaitingAction || !conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark event as submitted to prevent duplicate submissions
|
||||
addV1SubmittedEventId(awaitingAction.id);
|
||||
|
||||
// Call the V1 API endpoint
|
||||
respondToConfirmation({
|
||||
conversationId: conversation.conversation_id,
|
||||
conversationUrl: conversation.url || "",
|
||||
sessionApiKey: conversation.session_api_key,
|
||||
accept,
|
||||
});
|
||||
},
|
||||
[
|
||||
awaitingAction,
|
||||
conversation,
|
||||
addV1SubmittedEventId,
|
||||
respondToConfirmation,
|
||||
],
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!awaitingAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handleCancelShortcut = (event: KeyboardEvent) => {
|
||||
if (event.shiftKey && event.metaKey && event.key === "Backspace") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContinueShortcut = (event: KeyboardEvent) => {
|
||||
if (event.metaKey && event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleConfirmation(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Cancel: Shift+Cmd+Backspace (⇧⌘⌫)
|
||||
handleCancelShortcut(event);
|
||||
// Continue: Cmd+Enter (⌘↩)
|
||||
handleContinueShortcut(event);
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [awaitingAction, handleConfirmation]);
|
||||
|
||||
// Only show if agent is waiting for confirmation and we haven't already submitted
|
||||
if (
|
||||
curAgentState !== AgentState.AWAITING_USER_CONFIRMATION ||
|
||||
!awaitingAction ||
|
||||
v1SubmittedEventIds.includes(awaitingAction.id)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get security risk from the action (only ActionEvent has security_risk)
|
||||
const risk = isActionEvent(awaitingAction)
|
||||
? awaitingAction.security_risk
|
||||
: SecurityRisk.UNKNOWN;
|
||||
|
||||
const isHighRisk = risk === SecurityRisk.HIGH;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
{isHighRisk && (
|
||||
<RiskAlert
|
||||
content={t(I18nKey.CHAT_INTERFACE$HIGH_RISK_WARNING)}
|
||||
icon={<WarningIcon width={16} height={16} color="#fff" />}
|
||||
severity="high"
|
||||
title={t(I18nKey.COMMON$HIGH_RISK)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm font-normal text-white">
|
||||
{t(I18nKey.CHAT_INTERFACE$USER_ASK_CONFIRMATION)}
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<ActionTooltip
|
||||
type="reject"
|
||||
onClick={() => handleConfirmation(false)}
|
||||
/>
|
||||
<ActionTooltip
|
||||
type="confirm"
|
||||
onClick={() => handleConfirmation(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,9 +134,16 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
case "TaskTrackerObservation": {
|
||||
const { command } = event.observation;
|
||||
if (command === "plan") {
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
|
||||
} else {
|
||||
// command === "view"
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -19,6 +19,10 @@ const getFileEditorObservationContent = (
|
||||
): string => {
|
||||
const { observation } = event;
|
||||
|
||||
if (observation.error) {
|
||||
return `**Error:**\n${observation.error}`;
|
||||
}
|
||||
|
||||
const successMessage = getObservationResult(event) === "success";
|
||||
|
||||
// For view commands or successful edits with content changes, format as code block
|
||||
|
||||
@@ -11,9 +11,10 @@ export const getObservationResult = (
|
||||
switch (observationType) {
|
||||
case "ExecuteBashObservation": {
|
||||
const exitCode = observation.exit_code;
|
||||
const { metadata } = observation;
|
||||
|
||||
if (exitCode === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0) return "success"; // Command executed successfully
|
||||
if (exitCode === -1 || metadata.exit_code === -1) return "timeout"; // Command timed out
|
||||
if (exitCode === 0 || metadata.exit_code === 0) return "success"; // Command executed successfully
|
||||
return "error"; // Command failed
|
||||
}
|
||||
case "FileEditorObservation":
|
||||
|
||||
@@ -7,17 +7,6 @@ import {
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
// V1 events that should not be rendered
|
||||
const NO_RENDER_ACTION_TYPES = [
|
||||
"ThinkAction",
|
||||
// Add more action types that should not be rendered
|
||||
];
|
||||
|
||||
const NO_RENDER_OBSERVATION_TYPES = [
|
||||
"ThinkObservation",
|
||||
// Add more observation types that should not be rendered
|
||||
];
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
// Explicitly exclude system events that should not be rendered in chat
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
@@ -34,18 +23,12 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !NO_RENDER_ACTION_TYPES.includes(actionType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render observation events (with filtering)
|
||||
// Render observation events
|
||||
if (isObservationEvent(event)) {
|
||||
// For V1, observation is an object with kind property
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
// Note: ObservationEvent source is always "environment", not "user"
|
||||
// So no need to check for user source here
|
||||
|
||||
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Render message events (user and assistant messages)
|
||||
|
||||
+4
-5
@@ -1,19 +1,18 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
|
||||
import { getEventContent } from "../event-content-helpers/get-event-content";
|
||||
import { getObservationResult } from "../event-content-helpers/get-observation-result";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
|
||||
interface GenericEventMessageWrapperProps {
|
||||
event: OpenHandsEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function GenericEventMessageWrapper({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
isLastMessage,
|
||||
}: GenericEventMessageWrapperProps) {
|
||||
const { title, details } = getEventContent(event);
|
||||
|
||||
@@ -27,7 +26,7 @@ export function GenericEventMessageWrapper({
|
||||
}
|
||||
initiallyExpanded={false}
|
||||
/>
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
|
||||
interface ThoughtEventMessageProps {
|
||||
event: ActionEvent;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ThoughtEventMessage({
|
||||
event,
|
||||
actions,
|
||||
}: ThoughtEventMessageProps) {
|
||||
// Extract thought content from the action event
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
// If there's no thought content, don't render anything
|
||||
if (!thoughtContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
);
|
||||
}
|
||||
+4
-4
@@ -4,7 +4,7 @@ import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
import { ImageCarousel } from "../../../features/images/image-carousel";
|
||||
// TODO: Implement file_urls support for V1 messages
|
||||
// import { FileList } from "../../../features/files/file-list";
|
||||
import { ConfirmationButtons } from "#/components/shared/buttons/confirmation-buttons";
|
||||
import { V1ConfirmationButtons } from "#/components/shared/buttons/v1-confirmation-buttons";
|
||||
import { MicroagentStatusWrapper } from "../../../features/chat/event-message-components/microagent-status-wrapper";
|
||||
// TODO: Implement V1 LikertScaleWrapper when API supports V1 event IDs
|
||||
// import { LikertScaleWrapper } from "../../../features/chat/event-message-components/likert-scale-wrapper";
|
||||
@@ -13,7 +13,6 @@ import { MicroagentStatus } from "#/types/microagent-status";
|
||||
|
||||
interface UserAssistantEventMessageProps {
|
||||
event: MessageEvent;
|
||||
shouldShowConfirmationButtons: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
microagentPRUrl?: string;
|
||||
@@ -22,15 +21,16 @@ interface UserAssistantEventMessageProps {
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isLastMessage: boolean;
|
||||
}
|
||||
|
||||
export function UserAssistantEventMessage({
|
||||
event,
|
||||
shouldShowConfirmationButtons,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
microagentPRUrl,
|
||||
actions,
|
||||
isLastMessage,
|
||||
}: UserAssistantEventMessageProps) {
|
||||
const message = parseMessageFromEvent(event);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function UserAssistantEventMessage({
|
||||
<ImageCarousel size="small" images={imageUrls} />
|
||||
)}
|
||||
{/* TODO: Handle file_urls if V1 messages support them */}
|
||||
{shouldShowConfirmationButtons && <ConfirmationButtons />}
|
||||
{isLastMessage && <V1ConfirmationButtons />}
|
||||
</ChatMessage>
|
||||
<MicroagentStatusWrapper
|
||||
microagentStatus={microagentStatus}
|
||||
|
||||
@@ -14,14 +14,13 @@ import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
hasObservationPair: boolean;
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
messages: OpenHandsEvent[];
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@@ -37,8 +36,7 @@ interface EventMessageProps {
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
hasObservationPair,
|
||||
isAwaitingUserConfirmation,
|
||||
messages,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@@ -46,9 +44,6 @@ export function EventMessage({
|
||||
actions,
|
||||
isInLast10Actions,
|
||||
}: EventMessageProps) {
|
||||
const shouldShowConfirmationButtons =
|
||||
isLastMessage && event.source === "agent" && isAwaitingUserConfirmation;
|
||||
|
||||
const { data: config } = useConfig();
|
||||
|
||||
// V1 events use string IDs, but useFeedbackExists expects number
|
||||
@@ -74,19 +69,6 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
@@ -97,23 +79,53 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Action events - render thought + action (will be replaced by thought + observation)
|
||||
if (isActionEvent(event)) {
|
||||
return (
|
||||
<>
|
||||
<ThoughtEventMessage event={event} actions={actions} />
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Observation events - find the corresponding action and render thought + observation
|
||||
if (isObservationEvent(event)) {
|
||||
// Find the action that this observation is responding to
|
||||
const correspondingAction = messages.find(
|
||||
(msg) => isActionEvent(msg) && msg.id === event.action_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{correspondingAction && isActionEvent(correspondingAction) && (
|
||||
<ThoughtEventMessage event={correspondingAction} actions={actions} />
|
||||
)}
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
return (
|
||||
<UserAssistantEventMessage
|
||||
event={event as MessageEvent}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
{...commonProps}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events (including observation events)
|
||||
// Generic fallback for all other events
|
||||
return (
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
shouldShowConfirmationButtons={shouldShowConfirmationButtons}
|
||||
/>
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
@@ -9,30 +8,16 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[];
|
||||
isAwaitingUserConfirmation: boolean;
|
||||
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
|
||||
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, isAwaitingUserConfirmation }) => {
|
||||
({ messages, allEvents }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
@@ -42,8 +27,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isAwaitingUserConfirmation={isAwaitingUserConfirmation}
|
||||
messages={allEvents}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
@@ -5,6 +5,7 @@ import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useWebSocket, WebSocketHookOptions } from "#/hooks/use-websocket";
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
import { handleActionEventCacheInvalidation } from "#/utils/cache-utils";
|
||||
import { buildWebSocketUrl } from "#/utils/websocket-url";
|
||||
import type { V1SendMessageRequest } from "#/api/conversation-service/v1-conversation-service.types";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export type V1_WebSocketConnectionState =
|
||||
@@ -38,6 +40,7 @@ export type V1_WebSocketConnectionState =
|
||||
interface ConversationWebSocketContextType {
|
||||
connectionState: V1_WebSocketConnectionState;
|
||||
sendMessage: (message: V1SendMessageRequest) => Promise<void>;
|
||||
isLoadingHistory: boolean;
|
||||
}
|
||||
|
||||
const ConversationWebSocketContext = createContext<
|
||||
@@ -64,9 +67,16 @@ export function ConversationWebSocketProvider({
|
||||
const { addEvent } = useEventStore();
|
||||
const { setErrorMessage, removeErrorMessage } = useErrorMessageStore();
|
||||
const { removeOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
const { setAgentStatus } = useV1ConversationStateStore();
|
||||
const { setExecutionStatus } = useV1ConversationStateStore();
|
||||
const { appendInput, appendOutput } = useCommandStore();
|
||||
|
||||
// History loading state
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(true);
|
||||
const [expectedEventCount, setExpectedEventCount] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const receivedEventCountRef = useRef(0);
|
||||
|
||||
// Build WebSocket URL from props
|
||||
// Only build URL if we have both conversationId and conversationUrl
|
||||
// This prevents connection attempts during task polling phase
|
||||
@@ -78,16 +88,43 @@ export function ConversationWebSocketProvider({
|
||||
return buildWebSocketUrl(conversationId, conversationUrl);
|
||||
}, [conversationId, conversationUrl]);
|
||||
|
||||
// Reset hasConnected flag when conversation changes
|
||||
// Reset hasConnected flag and history loading state when conversation changes
|
||||
useEffect(() => {
|
||||
hasConnectedRef.current = false;
|
||||
setIsLoadingHistory(true);
|
||||
setExpectedEventCount(null);
|
||||
receivedEventCountRef.current = 0;
|
||||
}, [conversationId]);
|
||||
|
||||
// Check if we've received all events when expectedEventCount becomes available
|
||||
useEffect(() => {
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount &&
|
||||
isLoadingHistory
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}, [expectedEventCount, isLoadingHistory]);
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(messageEvent: MessageEvent) => {
|
||||
try {
|
||||
const event = JSON.parse(messageEvent.data);
|
||||
|
||||
// Track received events for history loading (count ALL events from WebSocket)
|
||||
// Always count when loading, even if we don't have the expected count yet
|
||||
if (isLoadingHistory) {
|
||||
receivedEventCountRef.current += 1;
|
||||
|
||||
if (
|
||||
expectedEventCount !== null &&
|
||||
receivedEventCountRef.current >= expectedEventCount
|
||||
) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Use type guard to validate v1 event structure
|
||||
if (isV1Event(event)) {
|
||||
addEvent(event);
|
||||
@@ -117,10 +154,10 @@ export function ConversationWebSocketProvider({
|
||||
// TODO: Tests
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
if (isFullStateConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value.agent_status);
|
||||
setExecutionStatus(event.value.execution_status);
|
||||
}
|
||||
if (isAgentStatusConversationStateUpdateEvent(event)) {
|
||||
setAgentStatus(event.value);
|
||||
setExecutionStatus(event.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,11 +178,13 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
[
|
||||
addEvent,
|
||||
isLoadingHistory,
|
||||
expectedEventCount,
|
||||
setErrorMessage,
|
||||
removeOptimisticUserMessage,
|
||||
queryClient,
|
||||
conversationId,
|
||||
setAgentStatus,
|
||||
setExecutionStatus,
|
||||
appendInput,
|
||||
appendOutput,
|
||||
],
|
||||
@@ -164,10 +203,26 @@ export function ConversationWebSocketProvider({
|
||||
return {
|
||||
queryParams,
|
||||
reconnect: { enabled: true },
|
||||
onOpen: () => {
|
||||
onOpen: async () => {
|
||||
setConnectionState("OPEN");
|
||||
hasConnectedRef.current = true; // Mark that we've successfully connected
|
||||
removeErrorMessage(); // Clear any previous error messages on successful connection
|
||||
|
||||
// Fetch expected event count for history loading detection
|
||||
if (conversationId) {
|
||||
try {
|
||||
const count = await EventService.getEventCount(conversationId);
|
||||
setExpectedEventCount(count);
|
||||
|
||||
// If no events expected, mark as loaded immediately
|
||||
if (count === 0) {
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fall back to marking as loaded to avoid infinite loading state
|
||||
setIsLoadingHistory(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
onClose: (event: CloseEvent) => {
|
||||
setConnectionState("CLOSED");
|
||||
@@ -188,7 +243,13 @@ export function ConversationWebSocketProvider({
|
||||
},
|
||||
onMessage: handleMessage,
|
||||
};
|
||||
}, [handleMessage, setErrorMessage, removeErrorMessage, sessionApiKey]);
|
||||
}, [
|
||||
handleMessage,
|
||||
setErrorMessage,
|
||||
removeErrorMessage,
|
||||
sessionApiKey,
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
// Only attempt WebSocket connection when we have a valid URL
|
||||
// This prevents connection attempts during task polling phase
|
||||
@@ -246,8 +307,8 @@ export function ConversationWebSocketProvider({
|
||||
}, [socket, wsUrl]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ connectionState, sendMessage }),
|
||||
[connectionState, sendMessage],
|
||||
() => ({ connectionState, sendMessage, isLoadingHistory }),
|
||||
[connectionState, sendMessage, isLoadingHistory],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { Provider } from "#/types/settings";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
|
||||
|
||||
/**
|
||||
* Gets the conversation version from the cache
|
||||
@@ -48,7 +49,7 @@ const fetchV1ConversationData = async (
|
||||
*/
|
||||
export const pauseV1ConversationSandbox = async (conversationId: string) => {
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.pauseSandbox(sandboxId);
|
||||
return SandboxService.pauseSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -75,7 +76,7 @@ export const stopV0Conversation = async (conversationId: string) =>
|
||||
*/
|
||||
export const resumeV1ConversationSandbox = async (conversationId: string) => {
|
||||
const { sandboxId } = await fetchV1ConversationData(conversationId);
|
||||
return V1ConversationService.resumeSandbox(sandboxId);
|
||||
return SandboxService.resumeSandbox(sandboxId);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { SecretsService } from "#/api/secrets-service";
|
||||
import { Provider, ProviderToken } from "#/types/settings";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
export const useAddGitProviders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackGitProviderConnected } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
@@ -11,7 +13,18 @@ export const useAddGitProviders = () => {
|
||||
}: {
|
||||
providers: Record<Provider, ProviderToken>;
|
||||
}) => SecretsService.addGitProvider(providers),
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (_, { providers }) => {
|
||||
// Track which providers were connected (filter out empty tokens)
|
||||
const connectedProviders = Object.entries(providers)
|
||||
.filter(([, value]) => value.token && value.token.trim() !== "")
|
||||
.map(([key]) => key);
|
||||
|
||||
if (connectedProviders.length > 0) {
|
||||
trackGitProviderConnected({
|
||||
providers: connectedProviders,
|
||||
});
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
},
|
||||
meta: {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import posthog from "posthog-js";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { SuggestedTask } from "#/utils/types";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { CreateMicroagent, Conversation } from "#/api/open-hands.types";
|
||||
import { USE_V1_CONVERSATION_API } from "#/utils/feature-flags";
|
||||
import { useTracking } from "#/hooks/use-tracking";
|
||||
|
||||
interface CreateConversationVariables {
|
||||
query?: string;
|
||||
@@ -31,6 +31,7 @@ interface CreateConversationResponse extends Partial<Conversation> {
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { trackConversationCreated } = useTracking();
|
||||
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
@@ -86,12 +87,11 @@ export const useCreateConversation = () => {
|
||||
is_v1: false,
|
||||
};
|
||||
},
|
||||
onSuccess: async (_, { query, repository }) => {
|
||||
posthog.capture("initial_query_submitted", {
|
||||
entry_point: "task_form",
|
||||
query_character_length: query?.length,
|
||||
has_repository: !!repository,
|
||||
onSuccess: async (_, { repository }) => {
|
||||
trackConversationCreated({
|
||||
hasRepository: !!repository,
|
||||
});
|
||||
|
||||
queryClient.removeQueries({
|
||||
queryKey: ["user", "conversations"],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import EventService from "#/api/event-service/event-service.api";
|
||||
import type { ConfirmationResponseRequest } from "#/api/event-service/event-service.types";
|
||||
|
||||
interface UseRespondToConfirmationVariables {
|
||||
conversationId: string;
|
||||
conversationUrl: string;
|
||||
sessionApiKey?: string | null;
|
||||
accept: boolean;
|
||||
}
|
||||
|
||||
export const useRespondToConfirmation = () =>
|
||||
useMutation({
|
||||
mutationKey: ["respond-to-confirmation"],
|
||||
mutationFn: async ({
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
accept,
|
||||
}: UseRespondToConfirmationVariables) => {
|
||||
const request: ConfirmationResponseRequest = {
|
||||
accept,
|
||||
};
|
||||
|
||||
return EventService.respondToConfirmation(
|
||||
conversationId,
|
||||
conversationUrl,
|
||||
request,
|
||||
sessionApiKey,
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -26,6 +26,13 @@ export const useUpdateConversation = () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Also optimistically update the active conversation query
|
||||
queryClient.setQueryData(
|
||||
["user", "conversation", variables.conversationId],
|
||||
(old: { title: string } | undefined) =>
|
||||
old ? { ...old, title: variables.newTitle } : old,
|
||||
);
|
||||
|
||||
return { previousConversations };
|
||||
},
|
||||
onError: (err, variables, context) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
|
||||
export const useBatchAppConversations = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["v1-batch-get-app-conversations", ids],
|
||||
queryFn: () => V1ConversationService.batchGetAppConversations(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import ConversationService from "#/api/conversation-service/conversation-service
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
|
||||
export interface BatchFeedbackData {
|
||||
exists: boolean;
|
||||
@@ -25,13 +26,20 @@ export const getFeedbackExistsQueryKey = (
|
||||
export const useBatchFeedback = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const queryClient = useQueryClient();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: getFeedbackQueryKey(conversationId),
|
||||
queryFn: () => ConversationService.getBatchFeedback(conversationId!),
|
||||
enabled: runtimeIsReady && !!conversationId && config?.APP_MODE === "saas",
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
!isV1Conversation,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SandboxService } from "#/api/sandbox-service/sandbox-service.api";
|
||||
|
||||
export const useBatchSandboxes = (ids: string[]) =>
|
||||
useQuery({
|
||||
queryKey: ["sandboxes", "batch", ids],
|
||||
queryFn: () => SandboxService.batchGetSandboxes(ids),
|
||||
enabled: ids.length > 0,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -2,14 +2,20 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import React from "react";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
|
||||
import { useRuntimeIsReady } from "../use-runtime-is-ready";
|
||||
import { useActiveConversation } from "./use-active-conversation";
|
||||
|
||||
export const useConversationConfig = () => {
|
||||
/**
|
||||
* @deprecated This hook is for V0 conversations only. Use useUnifiedConversationConfig instead,
|
||||
* or useV1ConversationConfig once we fully migrate to V1.
|
||||
*/
|
||||
export const useV0ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId],
|
||||
queryKey: ["v0_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
@@ -34,3 +40,80 @@ export const useConversationConfig = () => {
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const useV1ConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["v1_conversation_config", conversationId],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified hook that switches between V0 and V1 conversation config endpoints based on conversation version.
|
||||
*
|
||||
* @temporary This hook is temporary during the V0 to V1 migration period.
|
||||
* Once we fully migrate to V1, all code should use useV1ConversationConfig directly.
|
||||
*/
|
||||
export const useUnifiedConversationConfig = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["conversation_config", conversationId, isV1Conversation],
|
||||
queryFn: () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
if (isV1Conversation) {
|
||||
return V1ConversationService.getConversationConfig(conversationId);
|
||||
}
|
||||
return ConversationService.getRuntimeId(conversationId);
|
||||
},
|
||||
enabled: runtimeIsReady && !!conversationId && conversation !== undefined,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (query.data) {
|
||||
const { runtime_id: runtimeId } = query.data;
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"Runtime ID: %c%s",
|
||||
"background: #444; color: #ffeb3b; font-weight: bold; padding: 2px 4px; border-radius: 4px;",
|
||||
runtimeId,
|
||||
);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Keep the old export name for backward compatibility (uses unified approach)
|
||||
export const useConversationConfig = useUnifiedConversationConfig;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { BatchFeedbackData, getFeedbackQueryKey } from "./use-batch-feedback";
|
||||
|
||||
export type FeedbackData = BatchFeedbackData;
|
||||
@@ -9,6 +10,9 @@ export const useFeedbackExists = (eventId?: number) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: config } = useConfig();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
return useQuery<FeedbackData>({
|
||||
queryKey: [...getFeedbackQueryKey(conversationId), eventId],
|
||||
@@ -22,7 +26,11 @@ export const useFeedbackExists = (eventId?: number) => {
|
||||
|
||||
return batchData?.[eventId.toString()] ?? { exists: false };
|
||||
},
|
||||
enabled: !!eventId && !!conversationId && config?.APP_MODE === "saas",
|
||||
enabled:
|
||||
!!eventId &&
|
||||
!!conversationId &&
|
||||
config?.APP_MODE === "saas" &&
|
||||
!isV1Conversation,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import React from "react";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
import { useConversationConfig } from "./use-conversation-config";
|
||||
|
||||
/**
|
||||
* Unified hook to get active web host for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getWebHosts API endpoint and polls them
|
||||
* - V1: Gets worker URLs from sandbox exposed_urls (WORKER_1, WORKER_2, etc.)
|
||||
*/
|
||||
export const useUnifiedActiveHost = () => {
|
||||
const [activeHost, setActiveHost] = React.useState<string | null>(null);
|
||||
const { conversationId } = useConversationId();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const { data: conversationConfig, isLoading: isLoadingConfig } =
|
||||
useConversationConfig();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const sandboxId = conversationConfig?.runtime_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
// Get worker URLs from V1 sandbox or legacy web hosts from V0
|
||||
const { data, isLoading: hostsQueryLoading } = useQuery({
|
||||
queryKey: [conversationId, "unified", "hosts", isV1Conversation, sandboxId],
|
||||
queryFn: async () => {
|
||||
// V1: Get worker URLs from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return { hosts: [] };
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const workerUrls =
|
||||
sandbox.exposed_urls
|
||||
?.filter((url) => url.name.startsWith("WORKER_"))
|
||||
.map((url) => url.url) || [];
|
||||
|
||||
return { hosts: workerUrls };
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const hosts = await ConversationService.getWebHosts(conversationId);
|
||||
return { hosts };
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
initialData: { hosts: [] },
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Poll all hosts to find which one is active
|
||||
const apps = useQueries({
|
||||
queries: data.hosts.map((host) => ({
|
||||
queryKey: [conversationId, "unified", "hosts", host],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
await axios.get(host);
|
||||
return host;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
})),
|
||||
});
|
||||
|
||||
const appsData = apps.map((app) => app.data);
|
||||
|
||||
React.useEffect(() => {
|
||||
const successfulApp = appsData.find((app) => app);
|
||||
setActiveHost(successfulApp || "");
|
||||
}, [appsData]);
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? isLoadingConfig || sandboxesQuery.isLoading || hostsQueryLoading
|
||||
: hostsQueryLoading;
|
||||
|
||||
return { activeHost, isLoading };
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChange } from "#/api/open-hands.types";
|
||||
|
||||
/**
|
||||
* Unified hook to get git changes for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy GitService.getGitChanges API endpoint
|
||||
* - V1: Uses the V1GitService.getGitChanges API endpoint with runtime URL
|
||||
*/
|
||||
export const useUnifiedGetGitChanges = () => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const [orderedChanges, setOrderedChanges] = React.useState<GitChange[]>([]);
|
||||
const previousDataRef = React.useRef<GitChange[] | null>(null);
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const conversationUrl = conversation?.url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// Calculate git path based on selected repository
|
||||
const gitPath = React.useMemo(
|
||||
() => getGitPath(selectedRepository),
|
||||
[selectedRepository],
|
||||
);
|
||||
|
||||
const result = useQuery({
|
||||
queryKey: [
|
||||
"file_changes",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
conversationUrl,
|
||||
gitPath,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Use the V1 API endpoint with runtime URL
|
||||
if (isV1Conversation) {
|
||||
return V1GitService.getGitChanges(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
gitPath,
|
||||
);
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
return GitService.getGitChanges(conversationId);
|
||||
},
|
||||
retry: false,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
enabled: runtimeIsReady && !!conversationId,
|
||||
meta: {
|
||||
disableToast: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Latest changes should be on top
|
||||
React.useEffect(() => {
|
||||
if (!result.isFetching && result.isSuccess && result.data) {
|
||||
const currentData = result.data;
|
||||
|
||||
// If this is new data (not the same reference as before)
|
||||
if (currentData !== previousDataRef.current) {
|
||||
previousDataRef.current = currentData;
|
||||
|
||||
// Figure out new items by comparing with what we already have
|
||||
if (Array.isArray(currentData)) {
|
||||
const currentIds = new Set(currentData.map((item) => item.path));
|
||||
const existingIds = new Set(orderedChanges.map((item) => item.path));
|
||||
|
||||
// Filter out items that already exist in orderedChanges
|
||||
const newItems = currentData.filter(
|
||||
(item) => !existingIds.has(item.path),
|
||||
);
|
||||
|
||||
// Filter out items that no longer exist in the API response
|
||||
const existingItems = orderedChanges.filter((item) =>
|
||||
currentIds.has(item.path),
|
||||
);
|
||||
|
||||
// Add new items to the beginning
|
||||
setOrderedChanges([...newItems, ...existingItems]);
|
||||
} else {
|
||||
// If not an array, just use the data directly
|
||||
setOrderedChanges([currentData]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [result.isFetching, result.isSuccess, result.data]);
|
||||
|
||||
return {
|
||||
data: orderedChanges,
|
||||
isLoading: result.isLoading,
|
||||
isSuccess: result.isSuccess,
|
||||
isError: result.isError,
|
||||
error: result.error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import GitService from "#/api/git-service/git-service.api";
|
||||
import V1GitService from "#/api/git-service/v1-git-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { getGitPath } from "#/utils/get-git-path";
|
||||
import type { GitChangeStatus } from "#/api/open-hands.types";
|
||||
|
||||
type UseUnifiedGitDiffConfig = {
|
||||
filePath: string;
|
||||
type: GitChangeStatus;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Unified hook to get git diff for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy GitService.getGitChangeDiff API endpoint
|
||||
* - V1: Uses the V1GitService.getGitChangeDiff API endpoint with runtime URL
|
||||
*/
|
||||
export const useUnifiedGitDiff = (config: UseUnifiedGitDiffConfig) => {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
const conversationUrl = conversation?.url;
|
||||
const sessionApiKey = conversation?.session_api_key;
|
||||
const selectedRepository = conversation?.selected_repository;
|
||||
|
||||
// For V1, we need to convert the relative file path to an absolute path
|
||||
// The diff endpoint expects: /workspace/project/RepoName/relative/path
|
||||
const absoluteFilePath = React.useMemo(() => {
|
||||
if (!isV1Conversation) return config.filePath;
|
||||
|
||||
const gitPath = getGitPath(selectedRepository);
|
||||
return `${gitPath}/${config.filePath}`;
|
||||
}, [isV1Conversation, selectedRepository, config.filePath]);
|
||||
|
||||
return useQuery({
|
||||
queryKey: [
|
||||
"file_diff",
|
||||
conversationId,
|
||||
config.filePath,
|
||||
config.type,
|
||||
isV1Conversation,
|
||||
conversationUrl,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Use the V1 API endpoint with runtime URL and absolute path
|
||||
if (isV1Conversation) {
|
||||
return V1GitService.getGitChangeDiff(
|
||||
conversationUrl,
|
||||
sessionApiKey,
|
||||
absoluteFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint with relative path
|
||||
return GitService.getGitChangeDiff(conversationId, config.filePath);
|
||||
},
|
||||
enabled: config.enabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ConversationService from "#/api/conversation-service/conversation-service.api";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { transformVSCodeUrl } from "#/utils/vscode-url-helper";
|
||||
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
|
||||
import { useBatchAppConversations } from "./use-batch-app-conversations";
|
||||
import { useBatchSandboxes } from "./use-batch-sandboxes";
|
||||
|
||||
interface VSCodeUrlResult {
|
||||
url: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified hook to get VSCode URL for both legacy (V0) and V1 conversations
|
||||
* - V0: Uses the legacy getVSCodeUrl API endpoint
|
||||
* - V1: Gets the VSCode URL from sandbox exposed_urls
|
||||
*/
|
||||
export const useUnifiedVSCodeUrl = () => {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const runtimeIsReady = useRuntimeIsReady();
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
// Fetch V1 app conversation to get sandbox_id
|
||||
const appConversationsQuery = useBatchAppConversations(
|
||||
isV1Conversation && conversationId ? [conversationId] : [],
|
||||
);
|
||||
const appConversation = appConversationsQuery.data?.[0];
|
||||
const sandboxId = appConversation?.sandbox_id;
|
||||
|
||||
// Fetch sandbox data for V1 conversations
|
||||
const sandboxesQuery = useBatchSandboxes(sandboxId ? [sandboxId] : []);
|
||||
|
||||
const mainQuery = useQuery<VSCodeUrlResult>({
|
||||
queryKey: [
|
||||
"unified",
|
||||
"vscode_url",
|
||||
conversationId,
|
||||
isV1Conversation,
|
||||
sandboxId,
|
||||
],
|
||||
queryFn: async () => {
|
||||
if (!conversationId) throw new Error("No conversation ID");
|
||||
|
||||
// V1: Get VSCode URL from sandbox exposed_urls
|
||||
if (isV1Conversation) {
|
||||
if (
|
||||
!sandboxesQuery.data ||
|
||||
sandboxesQuery.data.length === 0 ||
|
||||
!sandboxesQuery.data[0]
|
||||
) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
const sandbox = sandboxesQuery.data[0];
|
||||
const vscodeUrl = sandbox.exposed_urls?.find(
|
||||
(url) => url.name === "VSCODE",
|
||||
);
|
||||
|
||||
if (!vscodeUrl) {
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: transformVSCodeUrl(vscodeUrl.url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// V0 (Legacy): Use the legacy API endpoint
|
||||
const data = await ConversationService.getVSCodeUrl(conversationId);
|
||||
|
||||
if (data.vscode_url) {
|
||||
return {
|
||||
url: transformVSCodeUrl(data.vscode_url),
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: null,
|
||||
error: t(I18nKey.VSCODE$URL_NOT_AVAILABLE),
|
||||
};
|
||||
},
|
||||
enabled:
|
||||
runtimeIsReady &&
|
||||
!!conversationId &&
|
||||
(!isV1Conversation || !!sandboxesQuery.data),
|
||||
refetchOnMount: true,
|
||||
retry: 3,
|
||||
});
|
||||
|
||||
// Calculate overall loading state including dependent queries for V1
|
||||
const isLoading = isV1Conversation
|
||||
? appConversationsQuery.isLoading ||
|
||||
sandboxesQuery.isLoading ||
|
||||
mainQuery.isLoading
|
||||
: mainQuery.isLoading;
|
||||
|
||||
// Explicitly destructure to avoid excessive re-renders from spreading the entire query object
|
||||
return {
|
||||
data: mainQuery.data,
|
||||
error: mainQuery.error,
|
||||
isLoading,
|
||||
isError: mainQuery.isError,
|
||||
isSuccess: mainQuery.isSuccess,
|
||||
status: mainQuery.status,
|
||||
refetch: mainQuery.refetch,
|
||||
};
|
||||
};
|
||||
@@ -3,30 +3,30 @@ import { useAgentStore } from "#/stores/agent-store";
|
||||
import { useV1ConversationStateStore } from "#/stores/v1-conversation-state-store";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { V1AgentStatus } from "#/types/v1/core/base/common";
|
||||
import { V1ExecutionStatus } from "#/types/v1/core/base/common";
|
||||
|
||||
/**
|
||||
* Maps V1 agent status to V0 AgentState
|
||||
*/
|
||||
function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
|
||||
function mapV1StatusToV0State(status: V1ExecutionStatus | null): AgentState {
|
||||
if (!status) {
|
||||
return AgentState.LOADING;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case V1AgentStatus.IDLE:
|
||||
case V1ExecutionStatus.IDLE:
|
||||
return AgentState.AWAITING_USER_INPUT;
|
||||
case V1AgentStatus.RUNNING:
|
||||
case V1ExecutionStatus.RUNNING:
|
||||
return AgentState.RUNNING;
|
||||
case V1AgentStatus.PAUSED:
|
||||
case V1ExecutionStatus.PAUSED:
|
||||
return AgentState.PAUSED;
|
||||
case V1AgentStatus.WAITING_FOR_CONFIRMATION:
|
||||
case V1ExecutionStatus.WAITING_FOR_CONFIRMATION:
|
||||
return AgentState.AWAITING_USER_CONFIRMATION;
|
||||
case V1AgentStatus.FINISHED:
|
||||
case V1ExecutionStatus.FINISHED:
|
||||
return AgentState.FINISHED;
|
||||
case V1AgentStatus.ERROR:
|
||||
case V1ExecutionStatus.ERROR:
|
||||
return AgentState.ERROR;
|
||||
case V1AgentStatus.STUCK:
|
||||
case V1ExecutionStatus.STUCK:
|
||||
return AgentState.ERROR; // Map STUCK to ERROR for now
|
||||
default:
|
||||
return AgentState.LOADING;
|
||||
@@ -41,7 +41,9 @@ function mapV1StatusToV0State(status: V1AgentStatus | null): AgentState {
|
||||
export function useAgentState() {
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0State = useAgentStore((state) => state.curAgentState);
|
||||
const v1Status = useV1ConversationStateStore((state) => state.agent_status);
|
||||
const v1Status = useV1ConversationStateStore(
|
||||
(state) => state.execution_status,
|
||||
);
|
||||
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user